diff --git a/README.md b/README.md new file mode 100644 index 0000000..5026a10 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# Crafter.Graphics + +Catcrafts' Vulkan-based graphics + UI library. C++20 modules, ray-traced 3D, compute-shader UI, fully bindless via `VK_EXT_descriptor_heap`. + +This is **V2** of the library — a from-scratch rewrite that replaces the +old `RenderingElement2D`-style UI (verbose, no batching, per-element +descriptor surgery) with a declarative widget tree rendered through a +single compute dispatch. + +## Capabilities + +- **3D rendering** through `VK_KHR_ray_tracing_pipeline`. `RTPass` is the + reusable wrapper; `Mesh` builds BLAS, `RenderingElement3D` builds TLAS. +- **2D / UI rendering** through one compute shader per frame. Widgets + emit `UIItem`s into a per-frame mapped SSBO; the shader scans it and + composites onto the swapchain image. SDF glyph atlas means one + texture covers all sizes / DPI scales. +- **Bindless descriptor model** via `VK_EXT_descriptor_heap` — one + resource heap + one sampler heap, bound once per frame. RT and UI + passes share the same heap. +- **`Window::passes`** — render passes are pluggable (`RenderPass*` + vector). Add `RTPass`, `UIScene`, your own pass, in any order. Window + inserts storage→storage barriers between consecutive passes. +- **Cross-platform window backend** — Wayland (with fractional scale + + XKB keyboard) or Win32, picked at compile time from the target triple. + +## Quick start + +```bash +# Build the library +cd Crafter.Graphics2 +crafter-build + +# Build + run an example +cd examples/VulkanUI +crafter-build -r +``` + +Build dependencies (cloned automatically): `Vulkan-Headers`, +`Vulkan-Utility-Libraries`, `Crafter.Event`, `Crafter.Math`, +`Crafter.Asset`. System dependencies: `clang++` with C++20 modules and +`libstd` PCM, `libvulkan`, `libwayland-client` + `xkbcommon` +(or `kernel32/user32/gdi32` on Windows). + +## Module layout + +The library is one C++20 module, `Crafter.Graphics`, with partitions +grouped by concern: + +| Partition family | Purpose | +|------------------------|------------------------------------------------------------| +| `:Window`, `:Device` | Window + Vulkan instance/device | +| `:RenderPass`, `:RTPass` | Pluggable pass interface + ray-tracing helper | +| `:DescriptorHeapVulkan`, `:VulkanBuffer`, `:ImageVulkan`, `:SamplerVulkan` | Bindless heap + GPU buffers / images / samplers | +| `:PipelineRTVulkan`, `:ShaderVulkan`, `:ShaderBindingTableVulkan` | RT pipeline plumbing | +| `:Mesh`, `:RenderingElement3D` | BLAS / TLAS for ray tracing | +| `:Font` | TTF loading + UTF-8 metrics | +| `:UI*` | Widget tree, layout, hit-testing, theme, draw list, atlas, renderer, scene | + +The umbrella `import Crafter.Graphics;` re-exports everything. + +## UI architecture (one paragraph) + +Widgets are value types with a fluent builder API. Composite containers +(`VStack`, `HStack`, `Stack`, `Overlay`, `TabView`, `ScrollView`) take +children as `&&` parameter packs and own them inside a `UIScene` arena. +Layout is two-pass measure/arrange (WPF / Avalonia / Flutter style) +with `Length::Px` / `Pct` / `Auto` / `Frac` units and DPI scaling +threaded through. Each frame, `UIScene` walks the tree, emits a flat +`UIItem` array into a mapped SSBO, and the compute shader scans it +front-to-back compositing rectangles, rounded rectangles, SDF glyphs, +and bindless images. Mouse clicks bubble through `OnMouseClick`; focus ++ `OnTextInput` / `OnKeyDown` route to the focused widget. Themes are +flat structs (`ButtonStyle`, `InputFieldStyle`) applied per-instance via +`.style(theme.primary)`. + +## Examples + +- [`examples/VulkanTriangle`](examples/VulkanTriangle/) — minimal + ray-traced triangle. The reference for `RTPass` + descriptor heap + setup with no UI. +- [`examples/VulkanUI`](examples/VulkanUI/) — the full Phase 2/3 + surface: stacks, themed buttons, progress bar, tab view, focusable + input fields with caret blink + key repeat. +- [`examples/VulkanAnimated`](examples/VulkanAnimated/) — `Observable` + + per-frame ticks driving live HUD bars and labels with no manual + invalidation. + +## V1 known limitations + +- Single-font, LTR-only text. No bold / italic / kerning beyond stb's + default. No multi-line wrap or BiDi. +- No tile-binning in the UI compute shader; the naive front-to-back + per-pixel scan handles a few hundred items effortlessly. Past + ~5,000 items the data layout supports tile-binning without an API + change. +- No render-target-to-texture for world-space UI yet. +- No animation primitives in the UI module — drive `Observable`s + yourself from `onUpdate`. + +## Status + +Phase 1, 2, and 3 V1 of the rewrite are complete: +ray-traced 3D path migrated to `RenderPass`, full UI widget set +rendering through compute, focus + keyboard input + Wayland key repeat +working end-to-end. Verified on NVIDIA GeForce RTX 4090 with +`VK_EXT_descriptor_heap` and `VK_LAYER_KHRONOS_validation` clean. + +## License + +LGPL v3 — see [LICENSE](LICENSE). diff --git a/claude-rewrite-plan.md b/claude-rewrite-plan.md deleted file mode 100644 index 2aa2199..0000000 --- a/claude-rewrite-plan.md +++ /dev/null @@ -1,435 +0,0 @@ -# UI System for Crafter.Graphics2 - -## Context - -The old UI system (still visible in [/home/jorijn/repos/Crafter/Crafter.Graphics/](../../repos/Crafter/Crafter.Graphics/)) was stripped out of Crafter.Graphics2 because it was painful to use. Real-world usages — [/home/jorijn/repos/3DForts/implementations/Forts3D-MainMenu.cpp](../../repos/3DForts/implementations/Forts3D-MainMenu.cpp) (250-line constructor) and [/home/jorijn/repos/3DForts/implementations/Forts3D-OptionsMenu.cpp](../../repos/3DForts/implementations/Forts3D-OptionsMenu.cpp) (510 lines, manual descriptor surgery on every page change) — show the symptoms: 7-param `Anchor2D` repeated everywhere, manual `parent.children.push_back`, manual per-frame `UpdatePosition`, 8-`EventListener`-fields-per-widget callback boilerplate, no widget abstraction at all (users compose `RenderingElement2D` + `MouseElement` + `EventListener` by hand), no batching, no glyph atlas, character-by-character glyph render every frame. - -The new system must be: -- User-friendly and future-proof (declarative composition, automatic layout, no manual lifetime juggling). -- At least as performant as the old one (single batched draw, glyph atlas, dirty-tracked layout). -- Cleanly integrated into the existing 3D ray-traced pipeline. **Any change that touches the 3D path needs explicit OK from the user.** -- CPU-runnable as a nice-to-have — but explicitly *not* via a custom CPU rasterizer if it can't beat llvmpipe. - -## Design summary - -**Three-layer architecture:** - -1. **Widget layer (user-facing):** declarative builder API. Widgets are value types with chained `.method()` configuration; composite containers (`VStack`, `HStack`, `Stack`, `Overlay`, `Grid`, `TabView`, `ScrollView`) take children as `&&` parameter packs and own them inside a `UIScene` arena. The only handle user code keeps is `WidgetRef` (a stable typed reference into the scene). -2. **Layout / event layer:** two-pass measure/arrange (WPF/Avalonia/Flutter convention). Hit-testing is automatic from the laid-out tree; events route via capture → tunnel → bubble. Focus is tree-cycled with Tab. -3. **Rendering layer:** widgets emit `UIItem` records into a single per-frame SSBO; one compute-shader dispatch composites everything onto the swapchain image. SDF glyph atlas means one texture serves all sizes/scales. - -**Key decisions (locked):** - -- **Compute shader, not graphics pipeline.** The swapchain has only `VK_IMAGE_USAGE_STORAGE_BIT` ([Crafter.Graphics-Window.cpp:958](../../repos/Crafter/Crafter.Graphics2/implementations/Crafter.Graphics-Window.cpp#L958)). A compute pass writes the same way the existing RT pipeline does, in the same `VK_IMAGE_LAYOUT_GENERAL`. No swapchain change, no renderpass machinery, no extra barriers. -- **SDF glyph atlas, not per-size raster.** One R8 atlas (1024² growable to 4096²) built lazily from `stb_truetype`. Survives DPI / fractional-scale changes without re-bake. Aliases cleanly at any scale via shader smoothstep. -- **No tile-binning in V1.** A naive front-to-back per-pixel item scan over a ≤500-item draw list comfortably hits sub-millisecond on integrated GPUs. The data layout supports adding tile-binning later without changing the public API. -- **No custom CPU rasterizer.** Same Vulkan code path runs on llvmpipe via the loader. Documented as the CPU fallback. -- **One descriptor heap, bound once per frame, never re-bound.** Per the [`VK_EXT_descriptor_heap` proposal](https://docs.vulkan.org/features/latest/features/proposals/VK_EXT_descriptor_heap.html), heap rebinds are expensive and applications should "stick to the same heap throughout the lifetime of the application". UIScene allocates its slots from the same `Window::descriptorHeap` the user already creates for 3D — never its own. Detail in §"Descriptor management". -- **Window owns a list of `RenderPass*`, not a single pipeline pointer.** Replaces the current `Window::pipeline` / `Window::descriptorHeap` direct-bind model. Future-proofs for post-processing, debug overlays, RTT, multi-pass effects. Detail in §"Window integration". -- **Layout is dirty-tracked.** `Observable::set()` marks owning widgets dirty; layout re-walks only the dirty subtree. -- **Text supports per-glyph styling** via `TextRun`. Each glyph is already a separate draw-list item, so per-run styling adds zero shader complexity — just per-item properties. - -## Window integration — `RenderPass` refactor - -The current `Window::Render` ([Crafter.Graphics-Window.cpp:730-844](../../repos/Crafter/Crafter.Graphics2/implementations/Crafter.Graphics-Window.cpp#L730-L844)) hardcodes a single ray-tracing pipeline: it binds `Window::pipeline` ([Crafter.Graphics-Window.cppm:193](../../repos/Crafter/Crafter.Graphics2/interfaces/Crafter.Graphics-Window.cppm#L193)) and calls `vkCmdTraceRaysKHR` directly. That assumption breaks the moment we want UI on top, post-processing, debug overlays, or any multi-pass effect. - -**Refactor**: replace the single-pipeline pointer with a vector of render passes. - -```cpp -// new module: Crafter.Graphics-RenderPass.cppm -struct RenderPass { - virtual void Record(VkCommandBuffer cmd, std::uint32_t frameIdx) = 0; - virtual ~RenderPass() = default; -}; - -// in Window (replaces `pipeline` and `descriptorHeap` pointer fields) -std::vector passes; -DescriptorHeapVulkan* descriptorHeap; // still here, but now SHARED across all passes -std::optional> clearColor; // optional initial clear; default nullopt -``` - -`Window::Render` becomes: - -1. `vkAcquireNextImageKHR`. -2. Begin command buffer. -3. Barrier `UNDEFINED → GENERAL` (existing, unchanged). -4. Bind shared descriptor heap (resource + sampler) **once**. -5. If `clearColor.has_value()`: `vkCmdClearColorImage`. -6. For each `pass : passes`: `pass->Record(cmd, currentBuffer);` — Window inserts a storage→storage memory barrier between consecutive passes that both write to the swapchain image. (V1: insert always; cheap enough. V2: pass-declared write/read sets.) -7. Barrier `GENERAL → PRESENT_SRC_KHR` (existing, unchanged). -8. End / submit / present (existing, unchanged). - -**3D usage** (replaces the inline `vkCmdTraceRaysKHR` in `Window::Render`): - -```cpp -// new helper struct shipped with the library: Crafter.Graphics-RTPass.cppm -struct RTPass : RenderPass { - PipelineRTVulkan* pipeline; - void Record(VkCommandBuffer cmd, std::uint32_t frame) override { - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_RAY_TRACING_KHR, pipeline->pipeline); - Device::vkCmdTraceRaysKHR(cmd, &pipeline->raygenRegion, &pipeline->missRegion, - &pipeline->hitRegion, &pipeline->callableRegion, - width, height, 1); - } -}; - -// user code (replaces existing pattern in VulkanTriangle/main.cpp:195-196) -RTPass rt{&pipeline}; -window.passes.push_back(&rt); -``` - -**UI usage**: `UIScene` *is* a `RenderPass` (publicly inherits or composes). Its constructor `push_back`s itself onto `window.passes`. The compute dispatch happens inside `UIScene::Record`. - -**UI-only scenes** (main menu with no 3D): the user simply doesn't add an `RTPass`. UI is the only pass. Optionally set `window.clearColor = {0,0,0,1}` for a known background. No stock pipeline, no `nullptr` checks needed. - -This **is** an API break: `VulkanTriangle/main.cpp` line 195-196 changes from `window.pipeline = &pipeline; window.descriptorHeap = &descriptorHeap;` to `window.descriptorHeap = &descriptorHeap; RTPass rt{&pipeline}; window.passes.push_back(&rt);`. The user OK'd this in feedback ("I don't mind breaking the existing API as long as it's discussed and done for a good reason"). The reason: future-proof multi-pass support, decouples Window from RT specifically. - -## Public API — concrete shape - -### Static main menu (replaces ~250 lines of [Forts3D-MainMenu.cpp:24-273](../../repos/3DForts/implementations/Forts3D-MainMenu.cpp#L24-L273)) - -```cpp -import Crafter.Graphics; -using namespace Crafter::UI; - -UIScene scene(window); - -scene.Root( - Stack{}.background(Image{assetFolder/"Background.tex"}).children( - Image{logoTex}.anchor(Anchor::TopCenter).size(Length::Pct(80), Length::Auto()), - - VStack{}.anchor(Anchor::BottomLeft).padding(8, 16).spacing(6).children( - Button{"Sandbox"}.style(themes.menu).onClick([&]{ SwitchScene(Active::Game); }), - Button{"Options"}.style(themes.menu).onClick([&]{ SwitchScene(Active::Options); }), - Button{"Exit"} .style(themes.exit).onClick([&]{ std::_Exit(0); }) - ), - - Text{std::format("V1.0.0-{}", BUILD_TARGET)} - .anchor(Anchor::BottomRight).padding(8).size(15).color(textColor) - ) -); -``` - -### Dynamic options screen with tabs and bound input fields - -```cpp -struct OptionsScene { - UIScene scene; - Options temp = options; - - OptionsScene(Window& w) : scene(w) { - scene.Root(VStack{}.children( - HStack{}.height(Length::Pct(7.5)).background(panelBg).padding(8).children( - Button{"Exit"}.onClick([&]{ SwitchScene(Active::Main); }), - Spacer{}, Text{"OPTIONS"}.size(30), Spacer{}, - Button{"Save"}.onClick([&]{ options = temp; SwitchScene(Active::Main); }) - ), - TabView{} - .tab("Graphics", VStack{}.spacing(4).children( - OptionRow{"Resolution"}.right(InputField{}.bind(temp.resX, temp.resY)), - OptionRow{"Max lights"}.right(InputField{}.bind(temp.maxLights)) - )) - .tab("Input", BuildInputPage()) - .tab("Audio", BuildAudioPage()) - )); - } -}; -``` - -`InputField{}.bind(member)` does both display (via `std::format`) and parse (via `std::from_chars`); no manual `TryParse`. `TabView` swaps content automatically on tab click — no manual erase/push_back/`UpdateElements`/`CreateBuffer`/`ReorderBuffer` (compare to [Forts3D-OptionsMenu.cpp:307-509](../../repos/3DForts/implementations/Forts3D-OptionsMenu.cpp#L307-L509)). - -### Frame-updated HUD overlay - -```cpp -Observable fps; -Observable health{100}; - -scene.Root(Overlay{}.children( - Text{}.bindFmt("{:.1f} fps", fps).anchor(Anchor::TopRight).padding(8), - ProgressBar{}.bindValue(health, 0, 100).anchor(Anchor::BottomCenter).size(Length::Px(300), Length::Px(20)) -)); - -window.onUpdate += [&](FrameTime t){ fps = 1.0 / t.delta.count(); health = currentHealth; }; -``` - -`Observable` mutation marks only its widget dirty; layout re-walks only that subtree; only the affected items in the SSBO are rewritten. - -### Per-glyph text styling - -Most call sites want a single style — that's the simple shorthand: - -```cpp -Text{"Welcome"}.size(24).color(textColor); -``` - -For mixed styling — common in HUDs ("Damage: 250" with the number colored), tooltips, dialog text, or future code-editor-like surfaces — pass an explicit list of `TextRun`: - -```cpp -Text{}.runs( - TextRun{"Health: "}.color(white), - TextRun{std::format("{}", hp)}.color(hp < 25 ? red : green).bold(), - TextRun{" / "}.color(grey), - TextRun{"100"}.color(white) -).size(20); // base size; runs can override -``` - -Each run can independently set `color`, `size`, `weight`, `italic`, `underline`, `strikethrough`, and (V2) `font`. Internally each glyph already produces its own `UIItem`, so per-run styling is just per-item properties — zero shader complexity. Layout splits the runs at line-wrap points naturally. - -Bound text uses the same machinery: - -```cpp -Text{}.bind(observableRuns); // Observable> -Text{}.bindFmt("{:.1f} fps", fpsObservable); // single-style shorthand for the common case -``` - -### User-defined composites - -```cpp -struct OptionRow { - std::string label; - Widget rightChild; - OptionRow(std::string l) : label(std::move(l)) {} - OptionRow& right(Widget w) && { rightChild = std::move(w); return *this; } - operator Widget() && { - return HStack{}.padding(4, 8).children( - Text{label}.size(18).expand(), std::move(rightChild) - ); - } -}; -``` - -No inheritance, no event-listener fields, no `parent.children.push_back`. A composite is just a function returning a `Widget`. - -## Layout system - -- **Two-pass measure/arrange.** `Measure(availableSize) → desiredSize`; `Arrange(finalRect)`. Cached per-node in `LayoutResult { Rect rectPx; Rect clipPx; }`. -- **Units:** `Length::Px(float)` (logical px), `Length::Pct(float)` (% of parent on same axis), `Length::Auto()` (use measured desired), `Length::Frac(float)` (weighted fill — Flutter `Expanded`). -- **DPI / fractional-scale:** `UIScene` reads `Window::scale` ([Crafter.Graphics-Window.cppm:136](../../repos/Crafter/Crafter.Graphics2/interfaces/Crafter.Graphics-Window.cppm#L136)) once per layout pass. `Px` values multiply by scale exactly once at logical→device conversion. `Pct` is scale-invariant by construction. -- **Containers V1:** `Stack`, `HStack`, `VStack`, `Overlay`, `Grid`. No CSS-style auto-everything. - -## Event / interaction model - -`UIScene` subscribes to existing `Window` events ([Crafter.Graphics-Window.cppm:75-95](../../repos/Crafter/Crafter.Graphics2/interfaces/Crafter.Graphics-Window.cppm#L75-L95)) — no new Window input API needed. - -- **Hit-testing:** top-down walk, gather containers whose `clipPx` contains cursor, pick topmost interactive leaf. -- **Routing:** capture (drag/modal) → tunnel (`onPreviewKeyDown`) → bubble (`onKeyDown`). Handler returns `Handled` to stop. -- **Focus:** `UIScene::focused` is a `WidgetRef<>`. Click grabs focus. Tab/Shift-Tab cycles focusables in tree order. `onTextInput` / `onKeyDown` start bubbling from `focused`. -- **Drag/scroll:** `Capture()` / `Release()` on the event arg. `ScrollView` captures wheel and drag-with-button-down. - -## Rendering pipeline - -### Per-frame draw list - -Single mapped SSBO `VulkanBuffer itemBuf[Window::numFrames]` — matches the existing 3-frame ring pattern ([Crafter.Graphics-Window.cppm:180](../../repos/Crafter/Crafter.Graphics2/interfaces/Crafter.Graphics-Window.cppm#L180)). - -```glsl -struct UIItem { - uint type; // 0=rect, 1=roundRect, 2=glyph, 3=image, 4=line, 5=clipPush, 6=clipPop - uint flags; - vec2 posPx; // top-left, device px - vec2 sizePx; - vec4 color; // primary - vec4 colorB; // gradient stop / shadow - vec4 uvRect; // glyph atlas uv or image source rect - uint imageIdx; // bindless image slot (0 = none / use atlas) - uint cornerRadiusPx; - vec2 reserved; -}; // 96 bytes -``` - -Tree walk emits items front-to-back. Clip stack is encoded inline as `clipPush`/`clipPop` items (Skia/Slate trick — keeps shader simple). - -### Compute shader (skeleton) - -`shaders/ui.comp.glsl` → `ui.spv`, loaded via existing [Crafter.Graphics-ShaderVulkan.cppm:39](../../repos/Crafter/Crafter.Graphics2/interfaces/Crafter.Graphics-ShaderVulkan.cppm#L39). - -```glsl -layout(local_size_x = 16, local_size_y = 16) in; -layout(binding = 0, rgba8) uniform image2D outImage; // swapchain -layout(binding = 1) buffer ItemBuffer { UIItem items[]; }; -layout(binding = 2) uniform sampler2D fontAtlas; // SDF -layout(binding = 3) uniform sampler2D bindlessImages[]; - -void main() { - ivec2 p = ivec2(gl_GlobalInvocationID.xy); - vec4 dst = imageLoad(outImage, p); // composite OVER existing 3D content - ClipStack stack; - for (uint i = 0; i < pc.itemCount; ++i) { - UIItem it = items[i]; - if (it.type == TYPE_CLIP_PUSH) { stack.push(it); continue; } - if (it.type == TYPE_CLIP_POP) { stack.pop(); continue; } - if (!stack.contains(p)) continue; - vec4 src = ShadeItem(it, vec2(p)); - dst = vec4(src.rgb + dst.rgb*(1-src.a), src.a + dst.a*(1-src.a)); - } - imageStore(outImage, p, dst); -} -``` - -Dispatch: `vkCmdDispatch(cmd, ceil(width/16), ceil(height/16), 1)` once per frame. Tile-binning V2 replaces the inner loop with a per-tile range without API change. - -### Glyph atlas - -- One `ImageVulkan` 1024² (growable). Created once at `UIScene` init. -- Shelf-allocator inserts glyphs lazily; `Font` already exposes `stbtt_fontinfo` ([Crafter.Graphics-Font.cppm:35](../../repos/Crafter/Crafter.Graphics2/interfaces/Crafter.Graphics-Font.cppm#L35)). -- SDF rasterized via `stbtt_GetGlyphSDF`. One atlas serves all sizes via shader-side smoothstep on screen-space derivative. -- Updates batched: layout collects missing glyphs per frame, single `ImageVulkan::Update` ([Crafter.Graphics-ImageVulkan.cppm:97](../../repos/Crafter/Crafter.Graphics2/interfaces/Crafter.Graphics-ImageVulkan.cppm#L97)) before dispatch. - -### Descriptor management — single shared heap - -**Hard rule**: the descriptor heap is bound exactly once per frame by `Window::Render`, and never re-bound. All passes (RT, UI, future post-processing) read from the same heap; they index into different *slot ranges* via push constants or specialization constants. - -This requires extending `DescriptorHeapVulkan` ([Crafter.Graphics-DescriptorHeapVulkan.cppm](../../repos/Crafter/Crafter.Graphics2/interfaces/Crafter.Graphics-DescriptorHeapVulkan.cppm)) with a slot-allocator API on top of its existing fixed-size pre-allocation: - -```cpp -struct DescriptorHeapVulkan { - // existing: resourceHeap[Window::numFrames], samplerHeap[Window::numFrames], etc. - - // new — bump allocators with optional free-lists in V2 - struct ImageSlotRange { std::uint16_t firstElement; std::uint16_t count; }; - struct BufferSlotRange { std::uint16_t firstElement; std::uint16_t count; }; - struct SamplerSlotRange{ std::uint16_t firstElement; std::uint16_t count; }; - - ImageSlotRange AllocateImageSlots(std::uint16_t count); - BufferSlotRange AllocateBufferSlots(std::uint16_t count); - SamplerSlotRange AllocateSamplerSlots(std::uint16_t count); - - // existing helpers GetBufferOffset / GetBufferOffsetElement still useful - // for translating a slot-range to a host-write address. -}; -``` - -**3D usage (migration)**: today, [VulkanTriangle/main.cpp:162-187](../../repos/Crafter/Crafter.Graphics2/examples/VulkanTriangle/main.cpp#L162-L187) hand-computes addresses with `descriptorHeap.bufferStartElement` and `descriptorHeap.bufferStartOffset`. Migrated: - -```cpp -auto rtImageSlots = descriptorHeap.AllocateImageSlots(3); // 3 swapchain views -auto rtTlasSlots = descriptorHeap.AllocateBufferSlots(3); // 3 TLAS addrs -// vkWriteResourceDescriptorsEXT writes into those specific offsets -// raygen.glsl reads them via specialization constants for the slot indices -``` - -**UI usage**: `UIScene::Initialize(window)` calls: - -```cpp -auto atlasSlot = window.descriptorHeap->AllocateImageSlots(1); -auto bindlessSlots = window.descriptorHeap->AllocateImageSlots(64); // Image{} widgets -auto itemBufSlots = window.descriptorHeap->AllocateBufferSlots(Window::numFrames); -auto samplerSlots = window.descriptorHeap->AllocateSamplerSlots(2); // linear + nearest -``` - -UI's compute shader uses these slot indices passed in via push constants. **No second heap, no rebind**. - -**Sizing the heap**: the user must size `DescriptorHeapVulkan::Initialize(images, buffers, samplers)` to fit *all* subsystems combined. The library can ship a `Window::EnsureDescriptorHeap(images, buffers, samplers)` helper that lazily creates a default-sized heap (e.g. 128 images, 32 buffers, 16 samplers) if the user hasn't explicitly created one — UI calls this first thing. - -**For UI-only scenes** (no 3D): user can skip creating a heap entirely; `UIScene` calls `EnsureDescriptorHeap` and gets a sensible default. - -### Dispatch site (inside `UIScene::Record(cmd, frame)`) - -1. `vkCmdBindPipeline(VK_PIPELINE_BIND_POINT_COMPUTE, uiPipeline)`. -2. (No heap binding — already bound by `Window::Render`.) -3. Push constants: item count, surface size, scale, slot-range starts (atlas, bindless base, item buf, samplers). -4. `vkCmdDispatch(cmd, ceil(width/16), ceil(height/16), 1)`. - -The `Window`-inserted storage→storage memory barrier between passes ensures the previous pass's writes are visible. - -## New files (matching existing convention) - -Add to [interfaces/](../../repos/Crafter/Crafter.Graphics2/interfaces/): - -| File | Role | -|---|---| -| `Crafter.Graphics-RenderPass.cppm` | `RenderPass` base. Used by both UI and 3D. | -| `Crafter.Graphics-RTPass.cppm` | `RTPass` helper that records a `vkCmdTraceRaysKHR` call from a `PipelineRTVulkan*`. | -| `Crafter.Graphics-UI.cppm` | `:UI` partition; re-exports the sub-partitions. | -| `Crafter.Graphics-UI-Length.cppm` | `Length`, `Anchor`, `Edges`, `Color`. | -| `Crafter.Graphics-UI-Widget.cppm` | `Widget` (handle), `WidgetRef`, `Observable`. | -| `Crafter.Graphics-UI-Widgets.cppm` | Stock widgets (see scope below) including `Text` + `TextRun`. | -| `Crafter.Graphics-UI-Layout.cppm` | Measure/arrange engine. | -| `Crafter.Graphics-UI-Hit.cppm` | Hit-testing + capture/tunnel/bubble router. | -| `Crafter.Graphics-UI-Theme.cppm` | `Theme` struct + `themes::default_dark`. | -| `Crafter.Graphics-UI-Atlas.cppm` | SDF glyph atlas atop `Font`. | -| `Crafter.Graphics-UI-DrawList.cppm` | `UIItem` + tree→buffer emitter. | -| `Crafter.Graphics-UI-Renderer.cppm` | Compute pipeline, per-frame item buffers, dispatch (implements `RenderPass`). | -| `Crafter.Graphics-UI-Scene.cppm` | `UIScene` — the only thing user code constructs; owns its `RenderPass` instance. | - -Add to [implementations/](../../repos/Crafter/Crafter.Graphics2/implementations/): -`Crafter.Graphics-UI-Layout.cpp`, `Crafter.Graphics-UI-Hit.cpp`, `Crafter.Graphics-UI-Atlas.cpp`, `Crafter.Graphics-UI-Renderer.cpp`, `Crafter.Graphics-UI-Scene.cpp`. (`RenderPass` and `RTPass` headers are header-only.) - -Add `shaders/ui.comp.glsl` — compiled to `ui.spv`, loaded via existing [Crafter.Graphics-ShaderVulkan.cppm:39](../../repos/Crafter/Crafter.Graphics2/interfaces/Crafter.Graphics-ShaderVulkan.cppm#L39) `ifstream` pattern. - -### Updates to existing files - -- [project.cpp](../../repos/Crafter/Crafter.Graphics2/project.cpp): bump `ifaces` array from 17 → 30 (RenderPass + RTPass + 11 UI), `impls` from 5 → 10. -- [interfaces/Crafter.Graphics.cppm](../../repos/Crafter/Crafter.Graphics2/interfaces/Crafter.Graphics.cppm): add `export import :RenderPass;`, `export import :RTPass;`, `export import :UI;`. -- [interfaces/Crafter.Graphics-Window.cppm](../../repos/Crafter/Crafter.Graphics2/interfaces/Crafter.Graphics-Window.cppm): replace `PipelineRTVulkan* pipeline;` field (line 193) with `std::vector passes;` and `std::optional> clearColor;`. Keep `DescriptorHeapVulkan* descriptorHeap;` (now shared across all passes). Add forward decl of `RenderPass`. -- [implementations/Crafter.Graphics-Window.cpp](../../repos/Crafter/Crafter.Graphics2/implementations/Crafter.Graphics-Window.cpp) `Window::Render` lines 730-844: replace the inline RT-bind-and-trace block (780-804) with: bind heap once (782-802 stays, lifted out of the per-pass code path), optional `vkCmdClearColorImage` if `clearColor`, then `for (auto* p : passes) { p->Record(cmd, currentBuffer); insertStorageBarrier(); }`. -- [interfaces/Crafter.Graphics-DescriptorHeapVulkan.cppm](../../repos/Crafter/Crafter.Graphics2/interfaces/Crafter.Graphics-DescriptorHeapVulkan.cppm): add slot-allocator API (`AllocateImageSlots`, `AllocateBufferSlots`, `AllocateSamplerSlots`) plus internal bump-allocator state. Existing static helpers stay. -- [examples/VulkanTriangle/main.cpp](../../repos/Crafter/Crafter.Graphics2/examples/VulkanTriangle/main.cpp): migrate to slot-allocator + `RTPass` + `window.passes.push_back`. Lines 162-196 are the affected region; replacement is shorter. - -## Migration story (for projects like 3DForts and `VulkanTriangle`) - -| Old | New | -|---|---| -| Compose `RenderingElement2D` + `MouseElement` + `EventListener` per widget | Stock widgets from `:UI-Widgets`. | -| 7-param `Anchor2D` everywhere | Builder methods (`anchor`, `padding`, `size`); often unneeded inside layout containers. | -| Manual `parent.children.push_back` | `.children(...)` | -| Per-frame `UpdateElements` / `CreateBuffer` / `ReorderBuffer` / `UpdatePosition` | Implicit; `UIScene` does it. | -| Manual `DescriptorHeapVulkan::Initialize` + `WriteDescriptors` per scene with hand-computed offsets | One shared `DescriptorHeapVulkan` for the application; subsystems call `AllocateImageSlots`/`AllocateBufferSlots`/`AllocateSamplerSlots`. | -| Custom `RaygenMenu.spv` shader per UI scene | Drop; UI uses one library compute shader. | -| `window.pipeline = &p; window.descriptorHeap = &h;` | `window.descriptorHeap = &h; window.passes.push_back(&pass);` for each pass (RT, UI, etc.). UI-only scenes don't push an RT pass — they just rely on `window.clearColor` for background. | - -The `VulkanTriangle` example migration is the smallest concrete example: lines 162-196 of [main.cpp](../../repos/Crafter/Crafter.Graphics2/examples/VulkanTriangle/main.cpp#L162-L196) shrink because slot offsets come from the allocator instead of being hand-computed; `window.pipeline = ...` becomes `RTPass rt{&pipeline}; window.passes.push_back(&rt);`. - -Migration is mostly *deletion*. A typical 350-line constructor becomes ~30 lines of declarative tree. - -## Verification plan - -1. **Build:** `cd Crafter.Graphics2 && crafter-build --vulkan` produces the static lib including all new modules. -2. **GPU smoke:** `examples/UI/main.cpp` (a new example) — a window showing nested stacks, a button that toggles its label, a focused text input, and a progress bar bound to an `Observable` driven from `onUpdate`. All on real GPU. -3. **CPU smoke:** same example with `VK_ICD_FILENAMES=…/lvp_icd.x86_64.json` to force llvmpipe. Confirm visual parity, framerate ≥30 fps for a static menu. -4. **Performance regression check:** port `Forts3D-MainMenu.cpp` and `Forts3D-OptionsMenu.cpp` to the new API; A/B compare frame time on the same hardware. Target: equal or better than old. -5. **3D pipeline non-regression:** after Phase 1, `examples/VulkanTriangle/main.cpp` (now using `RTPass` + `window.passes`) renders identically to its pre-refactor behavior — same triangle, same colors, same framerate. This is the gate before any UI code is written. -6. **Mixed scene:** a third example combines `VulkanTriangle`'s ray-traced triangle with an HUD overlay (two-pass: `RTPass` + `UIScene`); confirms the `RenderPass`/inter-pass-barrier integration works end-to-end and UI composites correctly over RT output. - -## Implementation order - -Roughly 18-20 working days (~4 weeks) for one engineer. **Phase 1 is the foundational refactor that touches the 3D path; it must land cleanly before any UI code is written, with `VulkanTriangle` working at every step.** - -**Phase 1 — refactor** (must keep `VulkanTriangle` running after each step): -1. Add `RenderPass` base + `RTPass` helper. ½ day. -2. Extend `DescriptorHeapVulkan` with slot-allocator API (bump allocators). ½ day. -3. Refactor `Window::Render`: replace single-pipeline bind/trace with `passes` loop, lift heap-bind out, add optional `clearColor` clear, insert inter-pass barriers. 1 day. -4. Migrate `VulkanTriangle/main.cpp` to the new API. ½ day. **Gate to Phase 2 — must run identically to today.** - -**Phase 2 — UI core**: -5. `UI-Length`, `UI-Widget` (values + handle + observable). 1 day. -6. `UI-Layout` measure/arrange engine. 1.5 days. -7. Stock widgets in priority order: stacks → Text (with `TextRun`) → Button → Image → InputField → ScrollView → TabView → ProgressBar. 3.5 days. -8. `UI-Theme`. ½ day. -9. `UI-Hit` (hit-test + capture/tunnel/bubble router). 1 day. - -**Phase 3 — UI rendering**: -10. `UI-Atlas` SDF on top of `Font`. 1.5 days. -11. `UI-DrawList`. ½ day. -12. `shaders/ui.comp.glsl`. 1 day, iterating with renderer. -13. `UI-Renderer` (`RenderPass`-implementing compute dispatcher). 2 days. -14. `UI-Scene` — wires everything to `Window` and registers itself in `window.passes`. ½ day. - -**Phase 4 — validation & migration**: -15. `examples/UI/main.cpp` showing static menu, dynamic input, HUD overlay over RT. 1 day. -16. Migrate `Forts3D-MainMenu` + `Forts3D-OptionsMenu` as the integration test. 1 day each. - -## Decisions locked from user feedback - -- **Window integration:** `Window::passes` refactor (not the `onAfterTrace` event) — chosen for future-proofing despite breaking `VulkanTriangle`. Migration is part of Phase 1. -- **Descriptor heap:** single shared heap, slot-allocator API on `DescriptorHeapVulkan`. **Never re-bound mid-frame.** This was the hard dealbreaker; the original "UI owns its own heap" plan is dropped. -- **Widget set V1:** `Stack`, `HStack`, `VStack`, `Overlay`, `Grid`, `Text` (with `TextRun`), `Button`, `Image`, `InputField` (string / integral / float / bound enum), `ScrollView`, `TabView`, `ProgressBar`, `Spacer`. Slider/Checkbox/Dropdown deferred to V2 unless requested. -- **Theming:** flat `Theme` struct with named slots, per-instance override via `.style(...)`. No cascading. -- **Animation:** the existing `Animation` is not woven into the UI API; users may drive `Observable` from any source (including `Animation` if they want). No `Animated` adapter, no `.fadeIn(2s)` shortcuts in V1. -- **Text V1:** single-font, LTR only, soft-wrap on space, kerning from stb. **Per-glyph styling is in V1** via `TextRun` (cheap to add, addresses the "text was a mess" feedback). ICU-grade BiDi/complex-shaping is V2+. -- **SPIR-V delivery:** `ifstream` the `.spv` (matches existing `VulkanShader` pattern). -- **Multi-window:** one `UIScene` per `Window`. Users wanting cross-window mirroring do it manually. -- **RTT / world-space UI:** V2. -- **Hot-reload:** V2 (nice to have, not blocking). - -- **Heap auto-creation:** `UIScene` calls `Window::EnsureDescriptorHeap(128 images, 32 buffers, 16 samplers)` if the user hasn't already attached one. Users wanting tighter control can pre-create their own heap before constructing `UIScene`. diff --git a/examples/Forts3DMainMenu/Inter.ttf b/examples/Forts3DMainMenu/Inter.ttf new file mode 100644 index 0000000..e31b51e Binary files /dev/null and b/examples/Forts3DMainMenu/Inter.ttf differ diff --git a/examples/Forts3DMainMenu/project.cpp b/examples/Forts3DMainMenu/project.cpp new file mode 100644 index 0000000..931c83c --- /dev/null +++ b/examples/Forts3DMainMenu/project.cpp @@ -0,0 +1,27 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span args) { + std::vector graphicsArgs(args.begin(), args.end()); + Configuration* graphics = LocalProject({ + .projectFile = "../../project.cpp", + .args = graphicsArgs, + }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "Forts3DMainMenu"; + cfg.outputName = "Forts3DMainMenu"; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { graphics }; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + cfg.files.push_back("Inter.ttf"); + + return cfg; +} diff --git a/examples/VulkanAnimated/Inter.ttf b/examples/VulkanAnimated/Inter.ttf new file mode 100644 index 0000000..e31b51e Binary files /dev/null and b/examples/VulkanAnimated/Inter.ttf differ diff --git a/examples/VulkanAnimated/README.md b/examples/VulkanAnimated/README.md new file mode 100644 index 0000000..21c1578 --- /dev/null +++ b/examples/VulkanAnimated/README.md @@ -0,0 +1,55 @@ +# VulkanAnimated + +A live HUD demo: three `Observable`s drive `ProgressBar`s, three +`Observable`s drive `Text` labels, and an FPS readout in +the corner ticks every frame. Everything updates from a single +`onUpdate` listener — no `Invalidate()` / `Redraw()` calls. + +## What it shows + +- **`Observable` data flow**: change a value in `onUpdate`, the + next frame's `RebuildFrame` re-emits the draw list with the new value + automatically. No tree rebuild. +- **`ProgressBar::bindValue(obs, lo, hi)`** — the bar fill normalises the + observable's current value into 0..1 each frame. +- **`Text::bind(observable)`** — the displayed string is sourced from + the observable each frame, replacing any baked-in runs. +- **Composition pattern**: a small lambda helper builds one HP/MP-style + row (`Text` + `ProgressBar` inside an `HStack`) given the observables + and a colour, then the row is dropped into the parent `VStack` like + any other widget. +- **Different update rates per observable**: `health` oscillates at 0.7 + rad/s, `mana` at 1.3, `charge` advances linearly modulo 1 — visible + proof that each observable updates independently. + +## Run + +```bash +cd examples/VulkanAnimated +crafter-build -r +``` + +You should see "Animated HUD" with three coloured bars (red HP, blue MP, +yellow Charge) all moving at different rates, with the FPS readout in +the top-right ticking once per frame. + +Click `Quit` to exit. + +## Notes + +The whole tick handler is just: + +```cpp +EventListener tick(&window.onUpdate, [&](FrameTime ft){ + t += ft.delta.count(); + health = 0.5f + 0.5f * std::sin(t * 0.7f); + mana = std::abs(std::sin(t * 1.3f)); + charge = std::fmod(t * 0.3f, 1.0f); + healthLabel = std::format("HP {:>3.0f} / 100", health.Get() * 100); + // … +}); +``` + +That's the entire animation system. There is deliberately no +`Animation` / tween primitive in the library — drive observables +from any source you like. diff --git a/examples/VulkanAnimated/main.cpp b/examples/VulkanAnimated/main.cpp new file mode 100644 index 0000000..697dbe4 --- /dev/null +++ b/examples/VulkanAnimated/main.cpp @@ -0,0 +1,115 @@ +#include "vulkan/vulkan.h" + +import Crafter.Graphics; +import Crafter.Event; +import Crafter.Math; +import std; + +using namespace Crafter; + +// A simple "game HUD" demo: three Observable drive ProgressBars at +// different rates / colours, while Observable labels feed the +// Text widgets next to them. UIScene::RebuildFrame re-emits each frame, so +// the only application code needed is "update the observables in +// onUpdate" — no manual Invalidate / Redraw calls. + +int main() { + Device::Initialize(); + Window window(1280, 720, "VulkanAnimated"); + window.StartInit(); + window.FinishInit(); + + Font font("Inter.ttf"); + UI::Theme theme = UI::themes::default_dark(); + + UI::UIScene scene; + scene.Initialize(window, "ui.comp.spv"); + scene.background(UI::Color{0.06f, 0.07f, 0.10f, 1.0f}); + + // ─── Observables ───────────────────────────────────────────────────── + UI::Observable health{1.0f}; + UI::Observable mana {0.5f}; + UI::Observable charge{0.0f}; + UI::Observable healthLabel; + UI::Observable manaLabel; + UI::Observable chargeLabel; + UI::Observable fpsLabel; + + // ─── Per-frame tick: drive the observables. UIScene re-emits on + // onUpdate, so any read of these values is automatically picked + // up in the next frame's draw list. + float t = 0.0f; + EventListener tick(&window.onUpdate, [&](FrameTime ft) { + const float dt = static_cast(ft.delta.count()); + t += dt; + + // Three offset waves at different rates / phases. + health = 0.5f + 0.5f * std::sin(t * 0.7f); + mana = std::abs(std::sin(t * 1.3f)); + charge = std::fmod(t * 0.3f, 1.0f); + + healthLabel = std::format("HP {:>3.0f} / 100", health.Get() * 100.0f); + manaLabel = std::format("MP {:>3.0f} / 100", mana.Get() * 100.0f); + chargeLabel = std::format("Charge {:>3.0f}%", charge.Get() * 100.0f); + fpsLabel = (dt > 0.0f) ? std::format("{:>5.1f} fps", 1.0f / dt) : std::string{"---.- fps"}; + }); + + // ─── Helper: one HP/MP-style row (label on the left, bar on the right). + // Build into a local (avoids returning a reference to a temporary + // that dies at the end of the chain expression). + auto bar = [&](UI::Observable& label, + UI::Observable& value, + UI::Color fg) -> UI::HStack { + UI::HStack h; + h.width(UI::Length::Frac(1)) + .spacing(12) + .children( + UI::Text{}.bind(label).font(font).size(16) + .width(UI::Length::Px(160)), + UI::ProgressBar{} + .bindValue(value, 0.0f, 1.0f) + .foreground(fg) + .size(UI::Length::Frac(1), UI::Length::Px(20)) + ); + return h; + }; + + scene.Root( + UI::VStack{} + .padding(28) + .spacing(16) + .children( + UI::HStack{} + .width(UI::Length::Frac(1)) + .children( + UI::Text{"Animated HUD"}.font(font).size(28), + UI::Spacer{}, + UI::Text{}.bind(fpsLabel).font(font).size(16) + .color(UI::Color{0.55f, 0.85f, 1.0f, 1.0f}) + ), + + UI::Text{"Three Observables drive the bars; " + "Observables drive the labels."} + .font(font).size(14).color(UI::Color{0.65f, 0.65f, 0.65f, 1}), + + bar(healthLabel, health, UI::Color{0.90f, 0.30f, 0.30f, 1.0f}), + bar(manaLabel, mana, UI::Color{0.30f, 0.55f, 0.95f, 1.0f}), + bar(chargeLabel, charge, UI::Color{0.95f, 0.85f, 0.30f, 1.0f}), + + UI::Spacer{}, + + UI::HStack{} + .width(UI::Length::Frac(1)) + .spacing(8) + .children( + UI::Spacer{}, + UI::Button{"Quit"}.font(font).style(theme.danger) + .onClick([]{ std::_Exit(0); }) + ) + ) + ); + + window.Render(); + window.StartUpdate(); + window.StartSync(); +} diff --git a/examples/VulkanAnimated/project.cpp b/examples/VulkanAnimated/project.cpp new file mode 100644 index 0000000..c9ab9f0 --- /dev/null +++ b/examples/VulkanAnimated/project.cpp @@ -0,0 +1,27 @@ +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +extern "C" Configuration CrafterBuildProject(std::span args) { + std::vector graphicsArgs(args.begin(), args.end()); + Configuration* graphics = LocalProject({ + .projectFile = "../../project.cpp", + .args = graphicsArgs, + }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "VulkanAnimated"; + cfg.outputName = "VulkanAnimated"; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { graphics }; + + std::array ifaces = {}; + std::array impls = { "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + cfg.files.push_back("Inter.ttf"); + + return cfg; +} diff --git a/examples/VulkanTriangle/README.md b/examples/VulkanTriangle/README.md index 6da7987..a287c5b 100644 --- a/examples/VulkanTriangle/README.md +++ b/examples/VulkanTriangle/README.md @@ -1,25 +1,38 @@ -# HelloWindow Example +# VulkanTriangle -## Description +The minimal ray-traced example. Renders a single static triangle through +`vkCmdTraceRaysKHR`. No UI. -This example demonstrates how to load shaders and render a triangle. +## What it shows -## Expected Result +- `Device::Initialize()` + `Window` + swapchain bring-up. +- A `DescriptorHeapVulkan` sized for one image + one buffer slot, with + slot ranges allocated via the bump-allocator API + (`AllocateImageSlots`, `AllocateBufferSlots`). +- A `PipelineRTVulkan` built from raygen / miss / closesthit SPIR-V + shaders compiled at build time. +- `Mesh::Build` constructing a BLAS and `RenderingElement3D::BuildTLAS` + the per-frame TLAS. +- Direct descriptor writes via `vkWriteResourceDescriptorsEXT` for the + swapchain views and TLAS device addresses. +- `RTPass{&pipeline}` plugged into `window.passes` — the canonical + way to add ray tracing to a window in this library. -A blue tinted vulkan window with a white triangle in the center. +It's the smallest sensible test of the bindless `VK_EXT_descriptor_heap` ++ ray-tracing path. -## Highlighted Code Snippet - -```cpp -EventListener listener(&window.onDraw, [&descriptors, &meshShader](VkCommandBuffer cmd){ - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, Pipeline::pipelineLayout, 0, 2, &descriptors.set[0], 0, NULL); - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, Pipeline::pipeline); - Device::vkCmdDrawMeshTasksEXTProc(cmd, meshShader.threadCount, 1, 1); -}); -``` - -## How to Run +## Run ```bash -crafter-build build executable -r -``` \ No newline at end of file +cd examples/VulkanTriangle +crafter-build -r +``` + +You should see a 1280×720 window with a triangle filling roughly the +centre. + +## Notes + +`raygen.glsl`'s `traceRayEXT` call is currently commented out — the +example exercises the dispatch and `imageStore` paths only. Uncomment +it to actually trace into the BLAS. diff --git a/examples/VulkanUI/README.md b/examples/VulkanUI/README.md new file mode 100644 index 0000000..9bd104f --- /dev/null +++ b/examples/VulkanUI/README.md @@ -0,0 +1,52 @@ +# VulkanUI + +A walking tour of the V1 widget set. UI-only (no 3D pass), so the entire +visible image comes from one compute dispatch per frame. + +## What it shows + +- **Layout**: nested `VStack` / `HStack` / `Spacer` / `TabView`, fluent + builder API, `Length::Px` / `Pct` / `Frac` / `Auto` units, DPI scaling + (your `Window::scale` flows through automatically). +- **Theming**: `themes::default_dark()` with `theme.primary` / `secondary` + / `danger` / `input` styles applied per-widget via `.style(...)`. +- **Text**: per-run colour styling via `TextRun`, an em-dash in the + header to confirm UTF-8 decoding works end-to-end. +- **Buttons**: rounded background (SDF in the shader), centred SDF + glyphs, `onClick` callbacks. Quit calls `_Exit(0)` so a working click + visibly closes the window. +- **Progress bar**: a `ProgressBar` at 42 %. +- **TabView**: three tabs (Graphics / Input / Audio); clicking the + tab bar swaps content. +- **InputField**: focusable text edits with caret blink, UTF-8 typing, + Backspace / Delete / arrow keys / Home / End, key repeat, horizontal + scrolling that keeps the caret visible, clipping that prevents + overflow from drawing past the field's bounds. + +## Run + +```bash +cd examples/VulkanUI +crafter-build -r +``` + +## Interactions to try + +| Action | Expected | +|---|---| +| Click `Play` / `Options` | Prints `[click] ...` to stderr | +| Click `Quit` | App exits | +| Click a tab label (Graphics / Input / Audio) | Tab body swaps | +| Click an `InputField` | Border turns blue, caret appears and blinks | +| Type | Characters appear at the caret, including multi-byte UTF-8 | +| Hold a letter | After ~500 ms the character starts repeating at ~25 Hz | +| Backspace / Delete | Removes one full UTF-8 codepoint | +| ← / → / Home / End | Moves the caret | +| Type past the right edge | Text scrolls left, caret stays visible | +| Click outside any input | Caret disappears (focus cleared) | + +## Notes + +The shader (`shaders/ui.comp.glsl` in the library) is compiled to +`ui.comp.spv` next to the binary by the build system. +The font (`Inter.ttf`) is bundled via `cfg.files.push_back`. diff --git a/implementations/Crafter.Graphics-Device.cpp b/implementations/Crafter.Graphics-Device.cpp index 55c8758..a287c7a 100644 --- a/implementations/Crafter.Graphics-Device.cpp +++ b/implementations/Crafter.Graphics-Device.cpp @@ -454,16 +454,32 @@ void Device::keyboard_key(void *data, wl_keyboard *keyboard, uint32_t serial, ui std::string buf; buf.resize(16); int n = xkb_state_key_get_utf8(xkb_state, keycode, buf.data(), 16); - if (n > 0) { - if ((unsigned char)buf[0] >= 0x20 && buf[0] != 0x7f) { - buf.resize(n); - focusedWindow->onTextInput.Invoke(buf); - } + std::string utf8; + if (n > 0 && (unsigned char)buf[0] >= 0x20 && buf[0] != 0x7f) { + buf.resize(n); + utf8 = buf; + focusedWindow->onTextInput.Invoke(utf8); } + + // Replace the active repeat with this key — most recent press wins, + // matching xkbcommon's typical behaviour and most desktop apps. + keyRepeat.active = (keyRepeat.rate > 0); + keyRepeat.key = crafterKey; + keyRepeat.utf8 = std::move(utf8); + keyRepeat.pressTime = std::chrono::steady_clock::now(); + keyRepeat.lastFireTime = keyRepeat.pressTime; } else { focusedWindow->heldkeys[(std::uint8_t)crafterKey] = false; focusedWindow->onKeyUp[(std::uint8_t)crafterKey].Invoke(); focusedWindow->onAnyKeyUp.Invoke(crafterKey); + + // If the released key was the one repeating, stop. Otherwise leave + // the existing repeat alone (user pressed/released a modifier + // mid-repeat etc.). + if (keyRepeat.active && keyRepeat.key == crafterKey) { + keyRepeat.active = false; + keyRepeat.utf8.clear(); + } } } @@ -472,7 +488,36 @@ void Device::keyboard_modifiers(void *data, wl_keyboard *keyboard, uint32_t seri } void Device::keyboard_repeat_info(void *data, wl_keyboard *keyboard, int32_t rate, int32_t delay) { + keyRepeat.rate = rate; + keyRepeat.delay = delay; + if (rate <= 0) keyRepeat.active = false; // compositor disabled repeat +} +void Device::TickKeyRepeats() { + if (!keyRepeat.active || !focusedWindow) return; + if (keyRepeat.rate <= 0) return; + + auto now = std::chrono::steady_clock::now(); + using ms = std::chrono::milliseconds; + auto sincePress = std::chrono::duration_cast(now - keyRepeat.pressTime).count(); + if (sincePress < keyRepeat.delay) return; + + auto period = std::chrono::milliseconds(1000 / keyRepeat.rate); + auto sinceLastFire = std::chrono::duration_cast(now - keyRepeat.lastFireTime).count(); + if (sinceLastFire < period.count()) return; + + // Catch up — emit one event per missed period so a paused frame doesn't + // make the repeat permanently lag behind. + while (now - keyRepeat.lastFireTime >= period) { + focusedWindow->onKeyDown[(std::uint8_t)keyRepeat.key].Invoke(); + focusedWindow->onAnyKeyDown.Invoke(keyRepeat.key); + focusedWindow->onKeyHold[(std::uint8_t)keyRepeat.key].Invoke(); + focusedWindow->onAnyKeyHold.Invoke(keyRepeat.key); + if (!keyRepeat.utf8.empty()) { + focusedWindow->onTextInput.Invoke(keyRepeat.utf8); + } + keyRepeat.lastFireTime += period; + } } void Device::seat_handle_capabilities(void* data, wl_seat* seat, uint32_t capabilities) { diff --git a/implementations/Crafter.Graphics-UIScene.cpp b/implementations/Crafter.Graphics-UIScene.cpp index 575eed3..737a2f9 100644 --- a/implementations/Crafter.Graphics-UIScene.cpp +++ b/implementations/Crafter.Graphics-UIScene.cpp @@ -123,10 +123,14 @@ void UIScene::Initialize(Window& window, const std::filesystem::path& spvPath) { } ); - // Per-frame: re-layout, emit, push items. + // Per-frame: re-layout, emit, push items. We capture FrameTime here + // so we can advance the scene's clock (caret blink, animations). updateListener_ = std::make_unique>( &window.onUpdate, - [this](FrameTime) { RebuildFrame(); } + [this](FrameTime ft) { + elapsedSec_ += static_cast(ft.delta.count()); + RebuildFrame(); + } ); } @@ -153,6 +157,7 @@ void UIScene::RebuildFrame() { drawList.atlas = &renderer.atlas; drawList.bindlessBaseHeapIdx = renderer.BindlessBaseHeapIdx(); drawList.scale = sc; + drawList.time = elapsedSec_; if (background_) { drawList.AddRect( { 0, 0, static_cast(window_->width), static_cast(window_->height) }, diff --git a/implementations/Crafter.Graphics-Window.cpp b/implementations/Crafter.Graphics-Window.cpp index 2f554df..c21b6b7 100644 --- a/implementations/Crafter.Graphics-Window.cpp +++ b/implementations/Crafter.Graphics-Window.cpp @@ -724,6 +724,10 @@ void Window::Render() { vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer], VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, 0, 0, nullptr, 0, nullptr, 1, &image_memory_barrier); + // Synthesise key-repeat events before listeners run, so the focused + // widget's OnTextInput / OnKeyDown sees them in the same frame. + Device::TickKeyRepeats(); + onUpdate.Invoke({startTime, startTime-lastFrameBegin}); #ifdef CRAFTER_TIMING totalUpdate = std::chrono::nanoseconds(0); diff --git a/interfaces/Crafter.Graphics-Device.cppm b/interfaces/Crafter.Graphics-Device.cppm index 7fbd501..2afd503 100644 --- a/interfaces/Crafter.Graphics-Device.cppm +++ b/interfaces/Crafter.Graphics-Device.cppm @@ -30,9 +30,26 @@ module; #endif export module Crafter.Graphics:Device; import std; +import :Types; // CrafterKeys for keyboard repeat state export namespace Crafter { struct Window; + + #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND + // Wayland's wl_keyboard.key only fires on real press/release — the + // compositor expects the application to synthesize repeat events + // itself using the rate/delay it advertises via wl_keyboard.repeat_info. + struct KeyRepeatState { + int rate = 25; // chars/sec + int delay = 500; // ms before first repeat + bool active = false; + CrafterKeys key{}; + std::string utf8; // UTF-8 to re-emit as onTextInput, if any + std::chrono::time_point pressTime; + std::chrono::time_point lastFireTime; + }; + #endif + struct Device { static void Initialize(); @@ -131,5 +148,18 @@ export namespace Crafter { static void CheckVkResult(VkResult result); static std::uint32_t GetMemoryType(std::uint32_t typeBits, VkMemoryPropertyFlags properties); + + // ─── Wayland key repeat ──────────────────────────────────────── + // TickKeyRepeats walks the held-key state and fires onKeyDown / + // onTextInput accordingly. Called once per frame from + // Window::Render. KeyRepeatState lives at namespace scope so its + // member initializers don't trip C++'s "complete-type-needed" + // rule for the inline static below. + #ifdef CRAFTER_GRAPHICS_WINDOW_WAYLAND + inline static KeyRepeatState keyRepeat; + static void TickKeyRepeats(); + #else + static void TickKeyRepeats() {} + #endif }; } \ No newline at end of file diff --git a/interfaces/Crafter.Graphics-UIDrawList.cppm b/interfaces/Crafter.Graphics-UIDrawList.cppm index 0a0da01..b1ebb26 100644 --- a/interfaces/Crafter.Graphics-UIDrawList.cppm +++ b/interfaces/Crafter.Graphics-UIDrawList.cppm @@ -74,6 +74,7 @@ export namespace Crafter::UI { FontAtlas* atlas = nullptr; std::uint32_t bindlessBaseHeapIdx = 0; // base heap slot for Image widgets float scale = 1.0f; // device scale (mirrors LayoutContext::scale) + float time = 0.0f; // seconds since scene init (drives blink etc.) void Reset() { items.clear(); } diff --git a/interfaces/Crafter.Graphics-UIScene.cppm b/interfaces/Crafter.Graphics-UIScene.cppm index 071ac25..c45bc5f 100644 --- a/interfaces/Crafter.Graphics-UIScene.cppm +++ b/interfaces/Crafter.Graphics-UIScene.cppm @@ -104,6 +104,7 @@ export namespace Crafter::UI { std::unique_ptr> textListener_; std::unique_ptr> keyListener_; Widget* focused_ = nullptr; + float elapsedSec_ = 0.0f; float WindowScale() const; void RebuildFrame(); diff --git a/interfaces/Crafter.Graphics-UIWidgets.cppm b/interfaces/Crafter.Graphics-UIWidgets.cppm index 27a0418..ce47c7d 100644 --- a/interfaces/Crafter.Graphics-UIWidgets.cppm +++ b/interfaces/Crafter.Graphics-UIWidgets.cppm @@ -376,6 +376,7 @@ export namespace Crafter::UI { Font* font_ = nullptr; float size_ = 16.0f; Color color_{1, 1, 1, 1}; + Observable* boundText_ = nullptr; Text() = default; Text(std::string s) { runs_.emplace_back(std::move(s)); } @@ -385,6 +386,11 @@ export namespace Crafter::UI { Text& size(float s) { size_ = s; return *this; } Text& color(Color c) { color_ = c; return *this; } + // Bind to an observable; the text is sourced from `obs` each frame + // (overriding any `runs_`). Useful for live-updating labels driven + // by a game-state observable. + Text& bind(Observable& obs) { boundText_ = &obs; return *this; } + // Replace the run list with a parameter pack of styled runs. template requires (std::convertible_to, TextRun> && ...) @@ -398,6 +404,9 @@ export namespace Crafter::UI { Size Measure(Size avail, const LayoutContext& ctx) override { float ownW = ResolveLength(width_, avail.w, ctx.scale, [&]{ if (!font_) return 0.0f; + if (boundText_) { + return static_cast(font_->GetLineWidth(boundText_->Get(), size_ * ctx.scale)); + } float w = 0; for (auto& r : runs_) { float rs = (r.size_.value_or(size_)) * ctx.scale; @@ -407,6 +416,9 @@ export namespace Crafter::UI { }); float ownH = ResolveLength(height_, avail.h, ctx.scale, [&]{ if (!font_) return 0.0f; + if (boundText_) { + return font_->LineHeight(size_ * ctx.scale); + } // Tallest run dictates the line height. float h = 0; for (auto& r : runs_) { @@ -425,6 +437,12 @@ export namespace Crafter::UI { void Emit(DrawList& dl) const override { if (!font_) return; + if (boundText_) { + detail::EmitText(dl, font_, boundText_->Get(), + size_ * dl.scale, color_, + computedRect.x, computedRect.y); + return; + } float cursorX = computedRect.x; for (auto& r : runs_) { float rs = r.size_.value_or(size_) * dl.scale; @@ -574,6 +592,7 @@ export namespace Crafter::UI { bool focused_ = false; std::size_t cursor_ = 0; // codepoint index within text_ std::string placeholder_; + float scrollX_ = 0.0f; // device-px offset to keep caret in view std::function onChange_; std::function onSubmit_; @@ -618,8 +637,32 @@ export namespace Crafter::UI { return desiredSize; } - void Arrange(Rect rect, const LayoutContext& /*ctx*/) override { + void Arrange(Rect rect, const LayoutContext& ctx) override { computedRect = rect; + + // Adjust horizontal scroll so the caret is always visible. + if (font_) { + EdgesPx p = ResolveEdges(padding_, ctx.scale); + float visibleW = std::max(0.0f, rect.w - p.Horiz()); + float devSize = fontSize_ * ctx.scale; + float caretW = std::max(1.0f, ctx.scale); + + std::string_view before(text_.data(), cursor_); + float prefixW = static_cast(font_->GetLineWidth(before, devSize)); + + // If the caret has run off the right edge, scroll right. + if (prefixW + caretW > scrollX_ + visibleW) { + scrollX_ = prefixW + caretW - visibleW; + } + // If the caret has moved left of the visible window, scroll left. + if (prefixW < scrollX_) { + scrollX_ = prefixW; + } + // Don't scroll past the start; don't scroll past content end. + float totalW = static_cast(font_->GetLineWidth(text_, devSize)); + float maxScroll = std::max(0.0f, totalW - visibleW); + scrollX_ = std::clamp(scrollX_, 0.0f, maxScroll); + } } // Interaction. UIScene's focus manager flips `focused_` via @@ -709,19 +752,36 @@ export namespace Crafter::UI { float devSize = fontSize_ * dl.scale; float originX = computedRect.x + p.left; float originY = computedRect.y + p.top; + float visibleW = std::max(0.0f, computedRect.w - p.Horiz()); + float visibleH = std::max(0.0f, computedRect.h - p.Vert()); + + // Clip the text + caret to the content rect — once the + // string overflows we slide it left under scrollX_, and + // anything past the left/right edges must not draw outside + // the field. + dl.PushClip({originX, computedRect.y + p.top, visibleW, visibleH}); + std::string_view show = !text_.empty() ? std::string_view(text_) : std::string_view(placeholder_); Color col = !text_.empty() ? textColor_ : Color{textColor_.r, textColor_.g, textColor_.b, textColor_.a * 0.5f}; - detail::EmitText(dl, font_, show, devSize, col, originX, originY); + detail::EmitText(dl, font_, show, devSize, col, originX - scrollX_, originY); - // Caret bar at the cursor position when focused. + // Caret bar at the cursor position when focused. Blink at + // ~530ms on / 530ms off (1.06s period) — standard rate + // across most desktop apps. if (focused_) { - std::string_view before(text_.data(), cursor_); - float prefixW = static_cast(font_->GetLineWidth(before, devSize)); - float caretX = originX + prefixW; - dl.AddRect({caretX, originY, t, font_->LineHeight(devSize)}, focusBorderColor_); + constexpr float kBlinkPeriod = 1.06f; + float phase = std::fmod(dl.time, kBlinkPeriod); + if (phase < kBlinkPeriod * 0.5f) { + std::string_view before(text_.data(), cursor_); + float prefixW = static_cast(font_->GetLineWidth(before, devSize)); + float caretX = originX - scrollX_ + prefixW; + dl.AddRect({caretX, originY, t, font_->LineHeight(devSize)}, focusBorderColor_); + } } + + dl.PopClip(); } } };