UI rewrite 3rd attempt

This commit is contained in:
Jorijn van der Graaf 2026-05-02 21:08:20 +02:00
commit 1f5697326c
48 changed files with 2155 additions and 6190 deletions

View file

@ -0,0 +1,61 @@
// Custom UI compute shader. Demonstrates the Tier 1 dispatch path:
// the user defines their own item struct, writes their own GLSL alongside
// the standard shaders (sharing the same UIDispatchHeader contract via
// ui-shared.glsl), and dispatches it via UIRenderer::Dispatch.
//
// What it does: each item is a circle. For every pixel the workgroup tile
// owns, if the pixel falls inside any item-circle, the pixel's RGB is
// inverted (1 - rgb). Composes naturally with whatever previous dispatches
// drew into the swapchain image — works on top of standard quads, custom
// effects, anything.
#version 460
#extension GL_GOOGLE_include_directive : enable
#include "ui-shared.glsl"
// Application-defined item: just (cx, cy, radius, _pad). Layout matches the
// C++ InverseCircleItem struct in main.cpp byte-for-byte under std430.
struct InverseCircleItem {
vec4 centerRadius;
};
layout(descriptor_heap, std430) readonly buffer InvCircleBuf {
InverseCircleItem items[];
} invCircleHeap[];
// NVIDIA workaround — same per-member-load pattern the library shaders use
// for the descriptor-heap'd SSBO composite-load issue.
InverseCircleItem LoadInverseCircleItem(uint heap, uint i) {
InverseCircleItem it;
it.centerRadius = invCircleHeap[heap].items[i].centerRadius;
return it;
}
layout(push_constant) uniform PC {
UIDispatchHeader hdr;
} pc;
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
void main() {
ivec2 screenPx;
if (!uiResolveScreenPixel(pc.hdr, screenPx)) return;
vec2 sp = vec2(screenPx) + 0.5;
// Find the strongest circle coverage at this pixel — using max instead
// of literal per-item invert so overlapping circles don't double-invert
// back to the original.
float coverage = 0.0;
for (uint i = 0u; i < pc.hdr.itemCount; ++i) {
InverseCircleItem it = LoadInverseCircleItem(pc.hdr.itemBuffer, i);
vec2 c = it.centerRadius.xy;
float r = it.centerRadius.z;
float d = length(sp - c) - r;
coverage = max(coverage, clamp(0.5 - d, 0.0, 1.0));
}
if (coverage <= 0.0) return;
vec4 dst = imageLoad(uiImages[pc.hdr.outImage], screenPx);
dst.rgb = mix(dst.rgb, vec3(1.0) - dst.rgb, coverage);
imageStore(uiImages[pc.hdr.outImage], screenPx, dst);
}

View file

@ -0,0 +1,105 @@
// Tier 1 demo: a user-authored compute shader dispatched alongside the
// standard ones. The custom shader inverts RGB in the area covered by a
// list of circles. The mouse-tracking circle moves; two static ones sit
// on a striped background drawn with the standard DrawQuads shader.
#include "vulkan/vulkan.h"
import Crafter.Graphics;
import Crafter.Event;
import std;
using namespace Crafter;
// Application-side item POD. Matches `struct InverseCircleItem { vec4
// centerRadius; }` in inverse-circle.comp.glsl byte-for-byte.
struct InverseCircleItem {
float cx, cy, radius, _pad;
};
int main() {
Device::Initialize();
Window window(1280, 720, "Custom Shader");
VkCommandBuffer init = window.StartInit();
DescriptorHeapVulkan heap;
heap.Initialize(/*images*/ 8, /*buffers*/ 8, /*samplers*/ 4);
window.descriptorHeap = &heap;
UIRenderer ui;
ui.Initialize(window, heap, init);
window.passes.push_back(&ui);
// Load the user-authored shader. Same wrapper as the four shipped with
// the library — there is no privileged path.
ComputeShader inverseCircle;
inverseCircle.Load("inverse-circle.comp.spv");
// User-owned buffers.
VulkanBuffer<QuadItem, true> quadsBuf;
VulkanBuffer<InverseCircleItem, true> invBuf;
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, 64);
invBuf.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, 16);
auto quadsSlot = ui.RegisterBuffer(quadsBuf);
auto invSlot = ui.RegisterBuffer(invBuf);
EventListener<UIBuildArgs> buildSub(&ui.onBuild, [&](UIBuildArgs a) {
VkCommandBuffer cmd = a.cmd;
Rect canvas = Rect::FromWindow(window);
// Six vertical stripes covering the canvas — gives the inverse
// circles something visibly different to invert.
std::array<std::array<float, 4>, 6> palette = {{
{0.95f, 0.30f, 0.30f, 1.0f},
{0.95f, 0.65f, 0.20f, 1.0f},
{0.95f, 0.95f, 0.20f, 1.0f},
{0.30f, 0.85f, 0.30f, 1.0f},
{0.20f, 0.55f, 0.95f, 1.0f},
{0.65f, 0.30f, 0.95f, 1.0f},
}};
std::uint32_t qc = 0;
float stripeW = canvas.w / 6.0f;
for (int i = 0; i < 6; ++i) {
quadsBuf.value[qc++] = QuadItem{
i * stripeW, 0, stripeW, canvas.h,
palette[i][0], palette[i][1], palette[i][2], palette[i][3],
0, 0, 0, 0,
0, 0, 0, 0,
};
}
// Three inverse circles: one tracking the mouse, two stationary.
std::uint32_t ic = 0;
invBuf.value[ic++] = { window.currentMousePos.x, window.currentMousePos.y, 100.0f, 0.0f };
invBuf.value[ic++] = { canvas.w * 0.25f, canvas.h * 0.5f, 60.0f, 0.0f };
invBuf.value[ic++] = { canvas.w * 0.75f, canvas.h * 0.5f, 80.0f, 0.0f };
// Standard dispatch first — paints the stripes.
if (qc > 0) {
quadsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
ui.DispatchQuads(cmd, quadsSlot, qc);
}
// Custom dispatch second — reads the stripes, inverts under
// circles, writes back. The library inserts the inter-dispatch
// SHADER_WRITE → SHADER_READ|WRITE barrier automatically.
if (ic > 0) {
invBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
struct PC { UIDispatchHeader hdr; } pc { ui.FillHeader(invSlot, ic) };
std::uint32_t gx = (window.width + 7) / 8;
std::uint32_t gy = (window.height + 7) / 8;
ui.Dispatch(cmd, inverseCircle, &pc, sizeof(pc), gx, gy, 1);
}
});
window.FinishInit();
window.Render();
window.StartUpdate();
window.StartSync();
}

View file

@ -4,16 +4,15 @@ namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
std::vector<std::string> graphicsArgs(args.begin(), args.end());
Configuration* graphics = LocalProject({
.projectFile = "../../project.cpp",
.args = graphicsArgs,
.args = std::vector<std::string>(args.begin(), args.end()),
});
Configuration cfg;
cfg.path = "./";
cfg.name = "VulkanAnimated";
cfg.outputName = "VulkanAnimated";
cfg.name = "CustomShader";
cfg.outputName = "CustomShader";
ApplyStandardArgs(cfg, args);
cfg.dependencies = { graphics };
@ -21,7 +20,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
cfg.files.push_back("Inter.ttf");
cfg.shaders.emplace_back(fs::path("inverse-circle.comp.glsl"), std::string("main"), ShaderType::Compute);
return cfg;
}

Binary file not shown.

View file

@ -1,27 +0,0 @@
import std;
import Crafter.Build;
namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
std::vector<std::string> 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<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
cfg.files.push_back("Inter.ttf");
return cfg;
}

BIN
examples/HelloUI/font.ttf Normal file

Binary file not shown.

180
examples/HelloUI/main.cpp Normal file
View file

@ -0,0 +1,180 @@
// Smoke test for the Tier 1+2+3 UI architecture. Opens a window, draws a
// background, a button (Tier 3 component), a slider (Tier 3 component), a
// progress bar (Tier 3 component), and a circle that follows the mouse
// (Tier 2 standard shader, dispatched directly). Hit-testing for the button
// and slider is the user's responsibility — see the onMouseMove listener.
#include "vulkan/vulkan.h"
import Crafter.Graphics;
import Crafter.Event;
import std;
using namespace Crafter;
int main() {
Device::Initialize();
Window window(1280, 720, "Hello UI");
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);
// User-owned per-shader buffers. Mapped, written each frame, dispatched
// by the user. Capacity is up to the user; resize means re-Register.
VulkanBuffer<QuadItem, true> quadsBuf;
VulkanBuffer<CircleItem, true> circlesBuf;
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);
circlesBuf.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, 64);
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 circlesSlot = ui.RegisterBuffer(circlesBuf);
auto glyphsSlot = ui.RegisterBuffer(glyphsBuf);
// Application-side state. Library doesn't track this; the user owns it.
Rect btnRect{};
Rect sliderRect{};
bool hovered = false;
bool sliderHovered = false;
bool dragging = false;
float sliderT = 0.42f;
float progress = 0.0f;
// Hit-testing — purely user code. EventListener objects hold the
// subscription; their destructors unregister at scope exit.
EventListener<void> moveSub(&window.onMouseMove, [&]() {
float mx = window.currentMousePos.x;
float my = window.currentMousePos.y;
hovered = btnRect.Contains(mx, my);
sliderHovered = sliderRect.Contains(mx, my);
if (dragging && sliderRect.w > 0) {
sliderT = std::clamp((mx - sliderRect.x) / sliderRect.w, 0.0f, 1.0f);
}
});
EventListener<void> clickSub(&window.onMouseLeftClick, [&]() {
if (sliderHovered) dragging = true;
});
EventListener<void> releaseSub(&window.onMouseLeftRelease, [&]() {
dragging = false;
});
// Application color palette. No library Theme — just a struct.
ButtonColors btnPalette{
.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.0f, 1.0f, 1.0f, 1.0f},
.border = {0.0f, 0.0f, 0.0f, 0.0f},
.cornerRadius = 8.0f,
.borderThickness = 0.0f,
};
SliderColors sliderPalette{
.track = {0.20f, 0.20f, 0.25f, 1.0f},
.trackFilled = {0.30f, 0.55f, 0.95f, 1.0f},
.thumb = {0.85f, 0.85f, 0.90f, 1.0f},
.thumbHover = {1.00f, 1.00f, 1.00f, 1.0f},
.trackHeight = 6.0f,
.thumbRadius = 10.0f,
};
ProgressColors progressPalette{
.bg = {0.15f, 0.15f, 0.18f, 1.0f},
.fill = {0.40f, 0.85f, 0.50f, 1.0f},
.cornerRadius = 4.0f,
};
EventListener<UIBuildArgs> buildSub(&ui.onBuild, [&](UIBuildArgs a) {
VkCommandBuffer cmd = a.cmd;
// Update demo progress.
progress = std::fmod(progress + 0.005f, 1.0f);
// Layout via SubRect — resize-safe.
Rect canvas = Rect::FromWindow(window);
Rect topBar = canvas.SubRect(80, Rect::Anchor::Top).Inset(20, 20, 10, 20);
btnRect = topBar.SubRect(160, Rect::Anchor::Left);
sliderRect = canvas.Inset(60).SubRect(20, Rect::Anchor::Top);
sliderRect.y = canvas.h * 0.5f;
sliderRect.x = 60.0f;
sliderRect.w = canvas.w - 120.0f;
Rect progressRect{ 60.0f, canvas.h * 0.5f + 60.0f, canvas.w - 120.0f, 16.0f };
// Reset per-frame counters.
std::uint32_t qc = 0, cc = 0, gc = 0;
UIBuffer buf{
.quads = quadsBuf.value,
.quadCount = &qc,
.quadCap = 256,
.glyphs = glyphsBuf.value,
.glyphCount = &gc,
.glyphCap = 4096,
.atlas = &atlas,
.renderer = &ui,
};
// Background quad — required because the swapchain is not cleared
// (no TRANSFER_DST_BIT on the swapchain image).
QuadItem bg{
canvas.x, canvas.y, canvas.w, canvas.h,
0.f, 0, 0, 1.f,
0, 0, 0, 0,
0, 0, 0, 0,
};
if (qc < buf.quadCap) buf.quads[qc++] = bg;
// Tier 3 components.
DrawButton(buf, btnRect,
hovered ? "Hovered!" : "Hover me",
hovered, /*pressed*/ false,
font, 18.0f, btnPalette);
DrawSlider(buf, sliderRect, sliderT, dragging, sliderPalette);
DrawProgressBar(buf, progressRect, progress, progressPalette);
// Tier 2 standard shader, used directly: a circle following the mouse.
circlesBuf.value[cc++] = CircleItem{
window.currentMousePos.x, window.currentMousePos.y, 6.0f, 0.0f,
1.0f, 1.0f, 1.0f, 0.85f,
0, 0, 0, 0,
};
// Flush + dispatch. The library inserts the inter-dispatch barriers.
if (qc > 0) {
quadsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
ui.DispatchQuads(cmd, quadsSlot, qc);
}
if (cc > 0) {
circlesBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
ui.DispatchCircles(cmd, circlesSlot, cc);
}
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

@ -4,16 +4,15 @@ namespace fs = std::filesystem;
using namespace Crafter;
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
std::vector<std::string> graphicsArgs(args.begin(), args.end());
Configuration* graphics = LocalProject({
.projectFile = "../../project.cpp",
.args = graphicsArgs,
.args = std::vector<std::string>(args.begin(), args.end()),
});
Configuration cfg;
cfg.path = "./";
cfg.name = "VulkanUI";
cfg.outputName = "VulkanUI";
cfg.name = "HelloUI";
cfg.outputName = "HelloUI";
ApplyStandardArgs(cfg, args);
cfg.dependencies = { graphics };
@ -21,7 +20,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
std::array<fs::path, 1> impls = { "main" };
cfg.GetInterfacesAndImplementations(ifaces, impls);
cfg.files.push_back("Inter.ttf");
cfg.files.push_back("font.ttf");
return cfg;
}

63
examples/README.md Normal file
View file

@ -0,0 +1,63 @@
# Examples
Each example is a self-contained `crafter-build` project that depends on
the parent `Crafter.Graphics` via `LocalProject`. To build and run any
of them:
```bash
cd examples/<name>
crafter-build -r
```
## Index
### [HelloWindow](HelloWindow/)
Minimum viable program: open a window, run the event loop. No Vulkan
rendering. Useful as a smoke test for `Device::Initialize` + `Window` +
the platform backend.
### [VulkanTriangle](VulkanTriangle/)
Ray-traced single triangle through `vkCmdTraceRaysKHR`. Shows the full
ray-tracing setup: `DescriptorHeapVulkan` with image and buffer slots,
`PipelineRTVulkan` from raygen / miss / closesthit SPIR-V, BLAS via
`Mesh::Build`, TLAS via `RenderingElement3D::BuildTLAS`, direct
`vkWriteResourceDescriptorsEXT` for swapchain views, `RTPass` on
`window.passes`. Smallest test of the bindless ray-tracing path.
### [HelloUI](HelloUI/)
Compute-shader UI demo using all three UI tiers:
- **Tier 3** components: `DrawButton`, `DrawSlider`, `DrawProgressBar`,
composed via `Rect::SubRect` for resize-safe layout.
- **Tier 2** standard shaders: `DispatchQuads` for the background and
components, `DispatchCircles` for a cursor-tracking dot,
`DispatchText` for the button label (with the FontAtlas wired up to
`UIRenderer`).
- **Tier 1** is available too — any custom `ComputeShader` registered
on the same heap can be dispatched alongside the standard ones.
Hit-testing and animation are user code (see the `EventListener`
subscriptions on `window.onMouseMove` / `onMouseLeftClick`); the
library does not track widgets or focus.
Drop a TTF in this directory as `font.ttf` before running (the example
loads it via `Font("font.ttf")`).
### [CustomShader](CustomShader/)
Tier 1 demo: a user-authored compute shader (`inverse-circle.comp.glsl`)
running alongside the shipped `drawQuads`. The custom shader inverts RGB
under each item-circle — exactly the kind of effect attempt #2's closed
shader couldn't express. Shows:
- Defining your own item POD struct in C++ + matching `std430` struct
in GLSL.
- `#include "../../shaders/ui-shared.glsl"` for the bindless heap
declarations + `UIDispatchHeader` push-constant contract.
- `ComputeShader::Load` for the `.spv`, `UIRenderer::RegisterBuffer`
for your SSBO, `FillHeader` to populate the standard prefix, and
`UIRenderer::Dispatch` to launch — the same pattern the standard
shaders use under the hood.
- The inter-dispatch SHADER_WRITE → SHADER_READ|WRITE barrier is
inserted automatically, so the custom shader sees the colored stripes
drawn by the prior `DispatchQuads` and reads/writes the swapchain
image safely.

Binary file not shown.

View file

@ -1,55 +0,0 @@
# VulkanAnimated
A live HUD demo: three `Observable<float>`s drive `ProgressBar`s, three
`Observable<std::string>`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<T>` 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<FrameTime> 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<T>` / tween primitive in the library — drive observables
from any source you like.

View file

@ -1,115 +0,0 @@
#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<float> drive ProgressBars at
// different rates / colours, while Observable<std::string> 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<float> health{1.0f};
UI::Observable<float> mana {0.5f};
UI::Observable<float> charge{0.0f};
UI::Observable<std::string> healthLabel;
UI::Observable<std::string> manaLabel;
UI::Observable<std::string> chargeLabel;
UI::Observable<std::string> 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<FrameTime> tick(&window.onUpdate, [&](FrameTime ft) {
const float dt = static_cast<float>(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<std::string>& label,
UI::Observable<float>& 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 Observable<float>s drive the bars; "
"Observable<std::string>s 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();
}

View file

@ -34,17 +34,17 @@ void main() {
1.0
));
traceRayEXT(
topLevelAS[bufferStart],
gl_RayFlagsNoneEXT,
0xff,
0, 0, 0,
origin,
0.001,
direction,
10000.0,
0
);
// traceRayEXT(
// topLevelAS[bufferStart],
// gl_RayFlagsNoneEXT,
// 0xff,
// 0, 0, 0,
// origin,
// 0.001,
// direction,
// 10000.0,
// 0
// );
imageStore(image[0], ivec2(pixel), vec4(hitValue, 1));
}

Binary file not shown.

View file

@ -1,52 +0,0 @@
# 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`.

View file

@ -1,85 +0,0 @@
#include "vulkan/vulkan.h"
import Crafter.Graphics;
import Crafter.Event;
import Crafter.Math;
import std;
using namespace Crafter;
int main() {
Device::Initialize();
Window window(1280, 720, "VulkanUI");
window.StartInit();
window.FinishInit();
Font font("Inter.ttf");
UI::Theme theme = UI::themes::default_dark();
theme.defaultFont = &font;
// ─────────────────────────────────────────────────────────────────────
// Wire the scene: it auto-creates a descriptor heap, plugs into the
// window's pass list, hooks mouse + update events, and drives a
// compute-shader UI pass per frame.
// ─────────────────────────────────────────────────────────────────────
UI::UIScene scene;
scene.Initialize(window, "ui.comp.spv");
scene.background(UI::Color{0.06f, 0.07f, 0.10f, 1.0f});
scene.Root(
UI::VStack{}
.padding(20)
.spacing(12)
.children(
UI::Text{"Crafter UI — V1"}.font(font).size(28),
UI::Text{}.font(font).size(14).runs(
UI::TextRun{"Click "},
UI::TextRun{"Quit"}.color(UI::Color{1.0f, 0.55f, 0.55f}).bold(),
UI::TextRun{" to close the window. Tabs switch on click. "},
UI::TextRun{"Have fun!"}.color(UI::Color{0.55f, 0.85f, 1.0f})
),
UI::HStack{}
.width(UI::Length::Frac(1))
.spacing(8)
.children(
UI::Button{"Play"} .font(font).style(theme.primary) .onClick([]{ std::println(std::cerr, "[click] Play"); }),
UI::Button{"Options"}.font(font).style(theme.secondary).onClick([]{ std::println(std::cerr, "[click] Options"); }),
UI::Spacer{},
UI::Button{"Quit"} .font(font).style(theme.danger) .onClick([]{ std::println(std::cerr, "[click] Quit"); std::_Exit(0); })
),
UI::ProgressBar{}
.value(0.42f)
.size(UI::Length::Frac(1), UI::Length::Px(20))
.foreground(theme.primary.background),
UI::TabView{}
.font(font)
.width(UI::Length::Frac(1))
.height(UI::Length::Px(220))
.tab("Graphics", UI::VStack{}.padding(8).spacing(8).children(
UI::Text{"Resolution"}.font(font).size(14),
UI::InputField{"1920x1080"}.font(font).style(theme.input),
UI::Text{"Max lights"}.font(font).size(14),
UI::InputField{"32"}.font(font).style(theme.input)
))
.tab("Input", UI::VStack{}.padding(8).spacing(8).children(
UI::Text{"Mouse sensitivity"}.font(font).size(14),
UI::InputField{"1.0"}.font(font).style(theme.input)
))
.tab("Audio", UI::VStack{}.padding(8).spacing(8).children(
UI::Text{"Master volume"}.font(font).size(14),
UI::InputField{"80"}.font(font).style(theme.input)
))
)
);
window.Render();
window.SaveFrame("frame.png");
window.StartUpdate(); // continuous rendering — UIScene re-emits per frame
window.StartSync();
}