Added EventBus logic and moved camera to be drawn on Containers

main
noah metz 2026-07-28 16:11:33 -06:00
parent 8252418db9
commit 1f2118f210
32 changed files with 2195 additions and 730 deletions

@ -5,11 +5,11 @@ LDFLAGS = -lfreetype -lz -lglfw -lvulkan -ldl -Xlinker -rpath -Xlinker /opt/home
CFLAGS += $(shell pkg-config --cflags lua) CFLAGS += $(shell pkg-config --cflags lua)
LDFLAGS += $(shell pkg-config --libs lua) LDFLAGS += $(shell pkg-config --libs lua)
ENGINE_SOURCES = src/engine.c src/draw.c src/ui.c src/ui_lua.c src/gpu.c src/hex.c src/hsv.c lib/spng.c lib/vma.cpp ENGINE_SOURCES = src/engine.c src/draw.c src/ui.c src/ui_lua.c src/events.c src/camera.c src/gpu.c src/hex.c src/hsv.c lib/spng.c lib/vma.cpp
APP_SOURCES = src/main.c $(ENGINE_SOURCES) APP_SOURCES = src/main.c $(ENGINE_SOURCES)
EDITOR_SOURCES = src/editor_main.c src/editor.c $(ENGINE_SOURCES) EDITOR_SOURCES = src/editor_main.c src/editor.c src/editor_lua.c $(ENGINE_SOURCES)
TEST_SOURCES = test/hsv.c $(ENGINE_SOURCES) TEST_SOURCES = test/hsv.c $(ENGINE_SOURCES)
TEST_EDITOR_SOURCES = test/editor.c src/editor.c $(ENGINE_SOURCES) TEST_EDITOR_SOURCES = test/editor.c src/editor.c src/editor_lua.c $(ENGINE_SOURCES)
APP_OBJECTS = $(addsuffix .o, $(basename $(APP_SOURCES))) APP_OBJECTS = $(addsuffix .o, $(basename $(APP_SOURCES)))
EDITOR_OBJECTS = $(addsuffix .o, $(basename $(EDITOR_SOURCES))) EDITOR_OBJECTS = $(addsuffix .o, $(basename $(EDITOR_SOURCES)))

@ -0,0 +1,78 @@
#ifndef CAMERA_H
#define CAMERA_H
#include <cglm/types.h>
#include "gpu.h"
// Forward declaration only (no #include "ui.h") - camera.h and ui.h are
// mutually referential (Container owns a Camera*, Camera targets a
// UIContext's texture pool) and neither needs more than a pointer to the
// other's type.
typedef struct UIContextStruct UIContext;
// GPU mirror of the proj/view a camera pushes to its render target's push
// constant; std430 layout must match shader/camera_common.glsl's Camera
// buffer_reference struct exactly.
typedef struct GPUCameraStruct {
mat4 proj;
mat4 view;
} GPUCamera;
// Orbital camera: looks at `position` from `distance` away, oriented by
// `rotation` (yaw, pitch). `view`/`proj` are derived state, stale until
// camera_update_view/camera_update_proj run - callers are responsible for
// calling them after changing position/rotation/distance, or after the
// camera's target size changes.
//
// A camera's on-screen placement isn't camera state - attach it to a
// Container (container_set_camera, ui.h) and that container's anchor/
// offset/size/z-order (container_order) becomes the camera's viewport and
// draw order, same as every other container. A camera can separately (or
// instead) render into an offscreen texture via camera_init_texture_target,
// for other pipelines/containers to sample.
typedef struct CameraStruct {
vec3 position;
vec2 rotation;
double distance;
mat4 view;
mat4 proj;
bool has_texture_target;
struct {
uint32_t width, height;
// Double-buffered per frame-in-flight: two separate vkQueueSubmits
// aren't hazard-free against each other without extra sync, so this
// mirrors the existing pattern (HexContext/Container GPU buffers)
// rather than adding a new semaphore.
uint32_t texture_slot[MAX_FRAMES_IN_FLIGHT];
VkImage depth_image[MAX_FRAMES_IN_FLIGHT];
VkImageView depth_image_view[MAX_FRAMES_IN_FLIGHT];
VmaAllocation depth_image_memory[MAX_FRAMES_IN_FLIGHT];
} texture;
VkBuffer gpu_buffer[MAX_FRAMES_IN_FLIGHT];
VmaAllocation gpu_buffer_memory[MAX_FRAMES_IN_FLIGHT];
VkDeviceAddress gpu_address[MAX_FRAMES_IN_FLIGHT];
} Camera;
VkResult create_camera(RenderContext* gpu, Camera* camera);
void camera_update_view(Camera* camera);
void camera_update_proj(Camera* camera, float aspect);
VkResult camera_sync_gpu(Camera* camera, RenderContext* gpu);
VkResult camera_init_texture_target(
RenderContext* gpu,
UIContext* ui,
Camera* camera,
uint32_t width,
uint32_t height);
void camera_destroy_texture_target(
RenderContext* gpu,
UIContext* ui,
Camera* camera);
#endif

@ -9,6 +9,7 @@ VkResult draw_frame(
RenderContext* context, RenderContext* context,
UIContext* ui, UIContext* ui,
HexContext* hex, HexContext* hex,
Camera* offscreen_camera,
double time); double time);
#endif #endif

@ -25,32 +25,52 @@ struct ModeKeyStruct {
}; };
struct EditorDataStruct { struct EditorDataStruct {
EditorMode mode;
ModeKey* mode_keys[MODE_MAX_ENUM]; ModeKey* mode_keys[MODE_MAX_ENUM];
uint32_t mode_key_counts[MODE_MAX_ENUM]; uint32_t mode_key_counts[MODE_MAX_ENUM];
// The full-window container hosting context->camera (see editor_startup/
// container_set_camera) - kept around so editor_frame_callback's resize
// check can update its size without a lookup every frame.
Container* main_container;
uint32_t selected_max; uint32_t selected_max;
uint32_t selected_count; uint32_t selected_count;
uint32_t* selected_regions; uint32_t* selected_regions;
uint32_t* selected_hexes; uint32_t* selected_hexes;
uint32_t* selected_vertices; uint32_t* selected_vertices;
bool hover_valid;
uint32_t hover_region;
uint32_t hover_hex;
uint32_t hover_vertex;
// Camera control scheme: WASD/scroll accumulate into these, which
// editor_frame_callback integrates into context->camera each frame. The
// camera itself (position/rotation/distance/view) is engine-owned
// (ClientContext.camera) - this is app policy for how input drives it,
// not camera state, so a different app (or a scripted sequence) can drive
// the same camera without this accumulator scheme at all.
vec3 velocity;
int32_t spin[2];
int32_t zoom;
float spin_speed;
float zoom_speed;
float move_speed;
}; };
EditorData* create_editor_data(void); EditorData* create_editor_data(void);
void editor_startup(ClientContext* context); void editor_startup(ClientContext* context);
void editor_frame_callback(ClientContext* context); void editor_frame_callback(ClientContext* context, double delta_time);
void editor_key_callback(ClientContext* context, int key, int action, int mods); void editor_key_callback(ClientContext* context, int key, int action, int mods);
void editor_scroll_callback(ClientContext* context, double x, double y); void editor_scroll_callback(ClientContext* context, double x, double y);
void editor_button_callback(ClientContext* context, float x, float y, int button, int action, int mods); void editor_button_callback(ClientContext* context, float x, float y, int button, int action, int mods);
void editor_cursor_callback(ClientContext* context, float x, float y);
void clear_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); EditorMode current_mode(ClientContext* context);
void enter_vertex_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods); void editor_set_mode(ClientContext* context, EditorMode mode);
void enter_neighbor_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods);
void enter_hex_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods);
void enter_region_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods);
void resize_selected(EditorData* data, unsigned int size); void resize_selected(EditorData* data, unsigned int size);
int32_t find_selected_hex(EditorData* data, uint32_t region, uint32_t hex); int32_t find_selected_hex(EditorData* data, uint32_t region, uint32_t hex);

@ -0,0 +1,10 @@
#ifndef EDITOR_LUA_H
#define EDITOR_LUA_H
#include "engine.h"
// Registers the `camera` global table (editor-specific, needs ClientContext,
// so it can't live in the engine-generic ui_lua.c)
void editor_lua_register(lua_State* L, ClientContext* context);
#endif

@ -5,6 +5,8 @@
#include "gpu.h" #include "gpu.h"
#include "ui.h" #include "ui.h"
#include "hex.h" #include "hex.h"
#include "events.h"
#include "camera.h"
typedef struct ClientContextStruct ClientContext; typedef struct ClientContextStruct ClientContext;
@ -37,7 +39,8 @@ typedef void (*app_cursor_callback)(
float y); float y);
typedef void (*app_frame_function)( typedef void (*app_frame_function)(
ClientContext* context); ClientContext* context,
double delta_time);
typedef void (*app_startup_function)( typedef void (*app_startup_function)(
ClientContext* context); ClientContext* context);
@ -48,19 +51,14 @@ struct ClientContextStruct {
RenderContext render; RenderContext render;
UIContext ui; UIContext ui;
HexContext hex; HexContext hex;
EventBus events;
vec3 position; Camera camera;
vec3 velocity;
// Nullable, non-owning. Set by the app to render a second camera's scene
vec2 rotation; // into an offscreen texture each frame (see camera_init_texture_target,
int32_t spin[2]; // camera.h) - NULL (the zero-init default) skips that pass entirely, so
// apps that don't opt in see no behavior change.
double distance; Camera* offscreen_camera;
int32_t zoom;
float spin_speed;
float zoom_speed;
float move_speed;
void* app_data; void* app_data;
app_frame_function app_frame; app_frame_function app_frame;

@ -0,0 +1,126 @@
#ifndef EVENTS_H
#define EVENTS_H
#include "ui.h"
// Source id for events/writes originating from C. Container ids are nonzero
// (load_container rejects 0), so 0 is free to mean "the engine".
#define EVENT_SOURCE_ENGINE 0
// Names under "engine." can only be emitted by C; app.emit rejects them.
// Property writes announce themselves as "engine.changed.<property>".
#define EVENT_ENGINE_PREFIX "engine."
#define EVENT_CHANGED_PREFIX "engine.changed."
// Dispatched directly (never queued) once per drain after the queue is
// exhausted, so handlers reliably run after every change event of the
// frame — the coalescing point for dirty-flag subscribers. Events emitted
// by its handlers queue for the next frame.
#define EVENT_FRAME "engine.frame"
// Max events processed per drain; hitting it means a handler cycle is
// endlessly re-emitting, so the remainder is dumped to stderr and dropped.
#define EVENT_DRAIN_MAX 1024
typedef enum PropertyTypeEnum {
PROPERTY_NUMBER,
PROPERTY_STRING,
PROPERTY_BOOL,
} PropertyType;
typedef struct PropertyStruct {
char* name;
PropertyType type;
union {
double number;
char* string; // owned; NULL until first set
bool boolean;
} value;
} Property;
// C subscriber. The event's packed args table is on the Lua stack at
// args_index (t[1..t.n]); it must still be there when the handler returns.
typedef void (*EventHandler)(
void* userdata,
const char* event,
uint32_t source,
lua_State* L,
int args_index);
typedef struct EventSubscriptionStruct {
char* event;
// Registry ref of the script this subscription was made from, restored as
// the ambient "current script" before invoking a Lua handler — so
// ui.create_overlay called from inside it attributes correctly. Not used
// for lifecycle: subscriptions currently live for the process, since
// nothing unloads a script (only overlays, which are a separate
// lifecycle — see events-design memory).
int script_env;
int lua_ref; // handler function ref, LUA_NOREF for C handlers
EventHandler handler; // NULL for Lua handlers
void* userdata;
// Removal during a drain marks instead of compacting so in-flight
// iteration stays valid; compaction happens when the drain finishes
uint8_t dead;
} EventSubscription;
typedef struct QueuedEventStruct {
char* name;
uint32_t source;
int args_ref; // registry ref to the packed args table
} QueuedEvent;
struct EventBusStruct {
lua_State* L;
EventSubscription* subs;
uint32_t sub_count;
uint32_t sub_cap;
QueuedEvent* queue;
uint32_t queue_count;
uint32_t queue_cap;
Property* properties;
uint32_t property_count;
uint32_t property_cap;
bool draining;
};
// Registers the `app` global (emit/subscribe/get/set) in the Lua state
VkResult event_bus_init(EventBus* bus, lua_State* L);
// Pops nargs values off the top of the Lua stack into the event's packed
// args and appends to the queue. Never dispatches; the queue drains once
// per frame from the top level.
VkResult event_emit(EventBus* bus, const char* name, uint32_t source, int nargs);
// Subscribes a C handler; Lua handlers subscribe via app.subscribe
VkResult event_subscribe(EventBus* bus, const char* name, EventHandler handler, void* userdata);
// Dispatches queued events in emit order, including events emitted by
// handlers during the drain, up to EVENT_DRAIN_MAX
void event_bus_drain(EventBus* bus, UIContext* ui, RenderContext* gpu, double delta_time);
VkResult event_property_register(EventBus* bus, const char* name, PropertyType type);
// Direct read access; NULL if unregistered
Property* event_property(EventBus* bus, const char* name);
// Setters no-op when the value is unchanged; otherwise they store the value
// and emit engine.changed.<name> with the new value as the single argument.
// Emission is unconditional on real changes regardless of who wrote.
VkResult event_property_set_number(EventBus* bus, const char* name, double value, uint32_t source);
VkResult event_property_set_string(EventBus* bus, const char* name, const char* value, uint32_t source);
VkResult event_property_set_bool(EventBus* bus, const char* name, bool value, uint32_t source);
// Arg accessors for C EventHandler bodies
int event_arg_count(lua_State* L, int args_index);
double event_arg_number(lua_State* L, int args_index, int i);
bool event_arg_bool(lua_State* L, int args_index, int i);
// Returned pointer is anchored by the args table; valid until the drain
// releases the event
const char* event_arg_string(lua_State* L, int args_index, int i);
#endif

@ -257,4 +257,15 @@ VkShaderModule load_shader_file(
VkResult recreate_framebuffer( VkResult recreate_framebuffer(
RenderContext* gpu); RenderContext* gpu);
VkResult create_depth_image(
VkDevice device,
VkFormat depth_format,
VkExtent2D extent,
VmaAllocator allocator,
VkCommandPool extra_graphics_pool,
GPUQueue graphics_queue,
VkImage* depth_image,
VmaAllocation* depth_image_memory,
VkImageView* depth_image_view);
#endif #endif

@ -2,11 +2,13 @@
#define HEX_H #define HEX_H
#include "gpu.h" #include "gpu.h"
#include "camera.h"
#include "vulkan/vulkan_core.h" #include "vulkan/vulkan_core.h"
#define MAX_RAYS 10 #define MAX_RAYS 10
#define MAX_HIGHLIGHTS 10 // Last slot of each is reserved for the hover preview (see editor.c).
#define MAX_POINTS 10 #define MAX_HIGHLIGHTS 11
#define MAX_POINTS 11
#define MAX_LOADED_REGIONS 2500 #define MAX_LOADED_REGIONS 2500
#define REGION_SIZE 10 #define REGION_SIZE 10
@ -77,8 +79,6 @@ typedef struct GPUPointStruct {
} GPUPoint; } GPUPoint;
typedef struct GPUHexContextStruct { typedef struct GPUHexContextStruct {
mat4 proj;
mat4 view;
uint32_t current_map; uint32_t current_map;
VkDeviceAddress rays; VkDeviceAddress rays;
VkDeviceAddress points; VkDeviceAddress points;
@ -115,6 +115,7 @@ typedef struct HexContextStruct {
typedef struct HexPushConstantStruct { typedef struct HexPushConstantStruct {
VkDeviceAddress context; VkDeviceAddress context;
VkDeviceAddress camera;
double time; double time;
} HexPushConstant; } HexPushConstant;
@ -156,15 +157,13 @@ bool ray_world_intersect(
bool edge_only, bool edge_only,
HexContext* context); HexContext* context);
VkResult update_hex_proj( // Recomputes the picking-ray inverse matrix (hex->inverse) from a camera's
RenderContext* gpu, // already-computed proj/view (see camera_update_proj/camera_update_view).
HexContext* hex); // Picking is only ever done against one camera at a time (the interactive
// one - see cursor_to_world_ray's full-window assumption), so callers pick
VkResult update_hex_view( // which camera's proj*view this reflects; it isn't tied to any specific one.
vec3 position, void update_hex_picking_inverse(
vec2 rotation, Camera* camera,
double distance,
RenderContext* gpu,
HexContext* hex); HexContext* hex);
void hex_qr(uint32_t hex, HexCoord* world); void hex_qr(uint32_t hex, HexCoord* world);

@ -130,6 +130,10 @@ typedef struct StringResourcesStruct {
typedef struct UIContextStruct UIContext; typedef struct UIContextStruct UIContext;
typedef struct ContainerStruct Container; typedef struct ContainerStruct Container;
typedef struct EventBusStruct EventBus;
// Forward declaration only (no #include "camera.h") - see camera.h's
// matching note on why these two headers don't include each other.
typedef struct CameraStruct Camera;
struct ContainerStruct { struct ContainerStruct {
VkBuffer container[MAX_FRAMES_IN_FLIGHT]; VkBuffer container[MAX_FRAMES_IN_FLIGHT];
@ -178,8 +182,21 @@ struct ContainerStruct {
uint32_t id; uint32_t id;
int script_env; // Lua registry ref for this container's environment table, 0 = no script // Non-owning, nullable. When set, this container's region (anchor/offset/
char* script_path; // size) and slot in container_order double as the camera's viewport and
// draw order - see container_set_camera/container_screen_rect.
Camera* camera;
// script_env: which script's dispatch handlers (on_button etc.) run for
// this container's input, 0 = no script. Set by ui.create_overlay, not by
// load_container — creation and script execution are decoupled; a single
// script_env can own multiple containers.
int script_env;
// Cached ref to this container's overlay handle userdata, so every
// dispatch call (and re-lookup from Lua) hands back the same object —
// required for scripts to compare handles by identity (overlay ==
// some_local). LUA_NOREF until ui.create_overlay makes one.
int overlay_ref;
}; };
typedef struct ContainerInputStruct { typedef struct ContainerInputStruct {
@ -187,8 +204,6 @@ typedef struct ContainerInputStruct {
uint32_t anchor; uint32_t anchor;
vec2 offset; vec2 offset;
vec2 size; vec2 size;
const char* script_path; // path to the .lua file declaring the container's elements
} ContainerInput; } ContainerInput;
typedef struct GPUUIContextStruct { typedef struct GPUUIContextStruct {
@ -228,6 +243,10 @@ struct UIContextStruct {
uint32_t max_containers; uint32_t max_containers;
Container* containers; Container* containers;
// Auto-incrementing id source for ui.create_overlay; starts at 1 so 0
// stays reserved for EVENT_SOURCE_ENGINE / "no container". Never reused,
// so a stale overlay handle's id can never collide with a live container.
uint32_t next_container_id;
// Container slot indices in draw order (back to front). Loading appends, // Container slot indices in draw order (back to front). Loading appends,
// so new containers spawn on top. // so new containers spawn on top.
@ -240,6 +259,10 @@ struct UIContextStruct {
lua_State* lua; lua_State* lua;
// Set by the app after event_bus_init; unload_container uses it to drop
// the container's event subscriptions
EventBus* events;
Container* active_container; Container* active_container;
uint32_t active_element; uint32_t active_element;
@ -267,11 +290,25 @@ VkResult load_texture(
UIContext* context, UIContext* context,
uint32_t* index); uint32_t* index);
// Registers a color-attachment-capable texture (COLOR_ATTACHMENT_BIT |
// SAMPLED_BIT, gpu->swapchain_format.format) in the same bindless slot pool
// load_texture uses, for a camera to render into and other pipelines to
// later sample - see camera_init_texture_target (camera.h).
VkResult create_render_target_texture(
RenderContext* gpu,
UIContext* context,
uint32_t width,
uint32_t height,
uint32_t* index);
VkResult load_container( VkResult load_container(
ContainerInput* container, ContainerInput* container,
RenderContext* gpu, RenderContext* gpu,
UIContext* context); UIContext* context);
// Allocates the next auto-incrementing container id for ui.create_overlay
uint32_t ui_alloc_container_id(UIContext* context);
VkResult unload_container( VkResult unload_container(
uint32_t id, uint32_t id,
RenderContext* gpu, RenderContext* gpu,
@ -279,6 +316,16 @@ VkResult unload_container(
void ui_container_to_front(uint32_t id, UIContext* ui); void ui_container_to_front(uint32_t id, UIContext* ui);
// Non-owning: caller keeps camera alive as long as it's attached. Pass NULL
// to detach (the container goes back to being a plain 2D overlay).
void container_set_camera(Container* container, Camera* camera);
// Converts a container's anchored region (point space, like anchor_offset)
// to a physical-pixel VkViewport/VkRect2D-compatible rect (window_scale
// space, like swapchain_extent) - the conversion a screen-region camera's
// viewport needs that anchor_offset alone doesn't provide.
void container_screen_rect(RenderContext* gpu, Container* container, VkRect2D* out);
// Runtime element management. Slots are stable for the element's lifetime; // Runtime element management. Slots are stable for the element's lifetime;
// destroyed slots are recycled by later creates. // destroyed slots are recycled by later creates.
VkResult ui_create_drawable( VkResult ui_create_drawable(

@ -3,27 +3,49 @@
#include "ui.h" #include "ui.h"
// Registers the `ui` global table and input constants in the Lua state // Registers the `ui`/`app` globals and input constants in the Lua state
void ui_lua_register(lua_State* L); void ui_lua_register(lua_State* L);
// Loads a script into a sandboxed environment, stores the env ref in c->script_env // Loads and runs a script file in its own sandboxed environment. Creation
VkResult ui_lua_load_container( // and script execution are decoupled: this makes no container. The script
// calls ui.create_overlay itself, from wherever it wants one — top level,
// a dispatch handler, or a bus subscription callback, any of which may run
// after this call returns, since app.subscribe can defer indefinitely.
VkResult ui_lua_run_script(
lua_State* L, lua_State* L,
Container* c,
UIContext* ui, UIContext* ui,
RenderContext* gpu, RenderContext* gpu,
const char* path); const char* path);
// Calls a global function in the container's script with one string argument // The container an input dispatch is currently calling into; NULL outside
VkResult ui_lua_call( // dispatch (top-level script execution, bus subscription callbacks) — those
UIContext* ui, // contexts have no single "current" overlay, so ui.rect/ui.text require one
RenderContext* gpu, // to have been created first.
Container* ui_lua_current_container(lua_State* L);
// Points the binding's registry context at a container (or NULL) before
// calling into a script from outside the normal dispatch path
void ui_lua_set_current(
lua_State* L,
Container* c, Container* c,
const char* function, UIContext* ui,
const char* argument); RenderContext* gpu);
// The script_env of whichever script is conceptually "running" right now —
// the script executing its top level, the script owning the container an
// input dispatch is calling into, or the script that registered the bus
// subscription currently being delivered. Used to attribute new overlays
// (ui.create_overlay) and new subscriptions (app.subscribe) to the right
// script. LUA_NOREF if nothing is running (shouldn't happen in practice).
int ui_lua_current_script(lua_State* L);
void ui_lua_set_current_script(lua_State* L, int script_env);
// Called by the engine input path. Each returns true if the container's script // Called by the engine input path. Each returns true if the container's script
// defined the handler and it returned a truthy value (event consumed). // defined the handler and it returned a truthy value (event consumed). The
// handler receives the dispatching container's overlay handle as its first
// argument, before element: a script's script_env may back more than one
// container, so a shared on_button etc. needs to know which one fired.
bool ui_lua_dispatch_button( bool ui_lua_dispatch_button(
UIContext* ui, UIContext* ui,
RenderContext* gpu, RenderContext* gpu,

@ -1,265 +0,0 @@
-- Color picker: element declarations and interaction logic.
-- z controls stacking within the container; equal z means don't overlap.
local bg = ui.rect{
pos = {0, 0},
size = {190, 150},
color = {0.4, 0.4, 0.4, 0.8},
z = 0,
}
local sv_square = ui.rect{
type = RECT_HSV,
pos = {2, 2},
size = {130, 130},
colors = {{0, 0, 1, 1}, {0, 1, 1, 1}, {0, 0, 0, 1}, {0, 1, 0, 1}},
events = EVENT_BUTTON,
z = 1,
}
local hue_bar = ui.rect{
type = RECT_HSV,
pos = {134, 2},
size = {10, 130},
colors = {{0, 1, 1, 1}, {0, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}},
events = EVENT_BUTTON + EVENT_SCROLL,
z = 1,
}
local sv_outline = ui.rect{
pos = {130-4, 130-4},
size = {7, 7},
color = {0, 0, 0, 1},
z = 2,
}
local sv_select = ui.rect{
type = RECT_HSV,
pos = {130-3, 130-3},
size = {5, 5},
color = {1, 0, 0, 1},
z = 3,
}
local hex_area = ui.rect{
pos = {20, 134},
size = {95, 15},
events = EVENT_BUTTON + EVENT_CURSOR,
z = 1,
}
local hue_select = ui.rect{
pos = {134, 2},
size = {10, 1},
color = {0, 0, 0, 1},
z = 2,
}
local hex_text = ui.text{
pos = {2, 150},
size = 16,
color = {1, 1, 1, 1},
font = 0,
max_length = 16,
z = 2,
}
-- 12 saved color slots in a 2-wide grid
local slots = {}
local slot_index = {}
for i = 1, 12 do
local slot = ui.rect{
pos = {146 + ((i-1) % 2)*22, 2 + math.floor((i-1)/2)*22},
size = {20, 20},
color = {0, 0, 0, 1},
events = EVENT_BUTTON,
z = 1,
}
slots[i] = slot
slot_index[slot] = i
end
local state = {
hsv = {0, 0, 0},
rgb = {0, 0, 0, 1},
saved = {},
hex_string = "#000000FF",
}
for i = 1, 12 do state.saved[i] = {0, 0, 0, 0} end
local function clamp01(x) return math.max(0, math.min(1, x)) end
local function update_hex_string()
state.hex_string = string.format("#%02X%02X%02X%02X",
math.floor(state.rgb[1]*255 + 0.5),
math.floor(state.rgb[2]*255 + 0.5),
math.floor(state.rgb[3]*255 + 0.5),
math.floor(state.rgb[4]*255 + 0.5))
hex_text:set_text(state.hex_string)
end
local function sync_rgb_from_hsv()
state.rgb[1], state.rgb[2], state.rgb[3] =
ui.hsv_to_rgb(state.hsv[1], state.hsv[2], state.hsv[3])
update_hex_string()
end
local function sv_pick(s, v)
s = clamp01(s)
v = clamp01(v)
state.hsv[2] = s
state.hsv[3] = v
sv_select:set_pos(s*130 - 2, 130 - v*130 - 2)
for corner = 0, 3 do
sv_select:set_corner_color(corner, state.hsv[1], s, v, 1)
end
sv_outline:set_pos(s*130 - 3, 130 - v*130 - 3)
sync_rgb_from_hsv()
end
local function hue_set(h)
h = clamp01(h)
state.hsv[1] = h
for _, element in ipairs({sv_square, sv_select}) do
for corner = 0, 3 do
local _, s, v, a = element:corner_color(corner)
element:set_corner_color(corner, h, s, v, a)
end
end
hue_select:set_pos(134, 2 + h*130)
sync_rgb_from_hsv()
end
local function hex_string_highlight(value)
for corner = 0, 3 do
local r, g, _, _ = hex_area:corner_color(corner)
hex_area:set_corner_color(corner, r, g, value, value)
end
end
local function set_saved(index, color)
state.saved[index] = {color[1], color[2], color[3], color[4]}
local slot = slots[index]
for corner = 0, 3 do
local _, _, _, a = slot:corner_color(corner)
slot:set_corner_color(corner, color[1], color[2], color[3], a)
end
end
-- Sync HSV+visuals from state.rgb; used after setting state.rgb directly.
local function apply_state_rgb()
state.hsv[1], state.hsv[2], state.hsv[3] =
ui.rgb_to_hsv(state.rgb[1], state.rgb[2], state.rgb[3])
hue_set(state.hsv[1])
sv_pick(state.hsv[2], state.hsv[3])
end
local drag = {
[sv_square] = function(x, y) sv_pick(x, 1 - y) end,
[hue_bar] = function(x, y) hue_set(y) end,
}
function on_button(element, x, y, button, action, mods)
if drag[element] then
if action == PRESS and button == MOUSE_LEFT then
element:focus()
drag[element](x, y)
elseif action == RELEASE and button == MOUSE_LEFT then
ui.blur()
end
return true
elseif element == hex_area then
if action == PRESS and button == MOUSE_LEFT then
element:focus()
hex_string_highlight(1)
end
return true
elseif slot_index[element] then
local index = slot_index[element]
if action == PRESS then
if button == MOUSE_LEFT then
local saved = state.saved[index]
state.rgb = {saved[1], saved[2], saved[3], saved[4]}
apply_state_rgb()
elseif button == MOUSE_RIGHT then
set_saved(index, state.rgb)
elseif button == MOUSE_MIDDLE then
set_saved(index, {0, 0, 0, 0})
end
end
return true
end
return false
end
function on_cursor(element, x, y)
for el, fn in pairs(drag) do
if el:is_focused() then
fn(x, y)
return true
end
end
return false
end
function on_scroll(element, x, y)
if element == hue_bar then
hue_set(state.hsv[1] + y*0.01)
return true
end
return false
end
function on_key(element, key, action, mods)
if element ~= hex_area then return false end
if action == PRESS then
if key == KEY_ESCAPE then
sync_rgb_from_hsv()
ui.blur()
elseif key == KEY_ENTER then
local s = state.hex_string
for i = 1, 4 do
state.rgb[i] = (tonumber(s:sub(2*i, 2*i + 1), 16) or 0) / 255
end
apply_state_rgb()
ui.blur()
elseif key == KEY_BACKSPACE then
if #state.hex_string > 1 then
state.hex_string = state.hex_string:sub(1, -2)
hex_text:set_text(state.hex_string)
end
end
end
return true
end
function on_text(element, codepoint)
if element ~= hex_area then return false end
if codepoint < 128 then
local ch = string.char(codepoint):upper()
if ch:match("[0-9A-F]") and #state.hex_string < 9 then
state.hex_string = state.hex_string .. ch
hex_text:set_text(state.hex_string)
end
end
return true
end
function on_deselect(element)
if element == hex_area then
hex_string_highlight(0)
end
end
-- runs once at container load
hex_text:set_text(state.hex_string)

@ -0,0 +1,17 @@
-- Mode-switch input handling: turns V/N/H/R/Escape presses into writes of
-- the editor.mode property. Separate from editor_ui.lua, which only reacts
-- to that property and doesn't care what triggers it.
local key_to_mode = {
[KEY_V] = "Vertex",
[KEY_N] = "Neighbor",
[KEY_H] = "Hex",
[KEY_R] = "Region",
[KEY_ESCAPE] = "None",
}
app.subscribe("engine.key", function(source, key, action, mods)
if action ~= PRESS then return end
local mode = key_to_mode[key]
if mode then app.set("editor.mode", mode) end
end)

@ -0,0 +1,337 @@
-- Editor UI controller. Loaded once at startup; owns the mode label
-- (always present) and the color picker (created/destroyed with the mode),
-- both driven by the editor.mode property. z controls stacking within an
-- overlay; equal z means don't overlap.
-- ============================== mode label ==============================
local label_overlay = ui.create_overlay{anchor = ANCHOR_TOP_LEFT, size = {160, 40}}
local label = ui.text{
pos = {0, 32},
size = 32,
color = {1, 1, 1, 1},
font = 0,
max_length = 8,
z = 0,
}
label:set_text(app.get("editor.mode") or "None")
-- ============================== color picker =============================
-- Elements/state are outer locals, assigned (not re-declared) each time
-- open_picker() runs, so the shared on_button etc. below always see
-- whichever picker is currently open.
local picker_overlay = nil
local bg, sv_square, hue_bar, sv_outline, sv_select
local hex_area, hue_select, hex_text, slots, slot_index, drag
local state
local function clamp01(x) return math.max(0, math.min(1, x)) end
local function update_hex_string()
state.hex_string = string.format("#%02X%02X%02X%02X",
math.floor(state.rgb[1]*255 + 0.5),
math.floor(state.rgb[2]*255 + 0.5),
math.floor(state.rgb[3]*255 + 0.5),
math.floor(state.rgb[4]*255 + 0.5))
hex_text:set_text(state.hex_string)
-- Publish for C and other overlays; only ever called with a complete
-- color, so editor.color never holds a half-typed hex string
app.set("editor.color", state.hex_string)
end
local function sync_rgb_from_hsv()
state.rgb[1], state.rgb[2], state.rgb[3] =
ui.hsv_to_rgb(state.hsv[1], state.hsv[2], state.hsv[3])
update_hex_string()
end
local function sv_pick(s, v)
s = clamp01(s)
v = clamp01(v)
state.hsv[2] = s
state.hsv[3] = v
sv_select:set_pos(s*130 - 2, 130 - v*130 - 2)
for corner = 0, 3 do
sv_select:set_corner_color(corner, state.hsv[1], s, v, 1)
end
sv_outline:set_pos(s*130 - 3, 130 - v*130 - 3)
sync_rgb_from_hsv()
end
local function hue_set(h)
h = clamp01(h)
state.hsv[1] = h
for _, element in ipairs({sv_square, sv_select}) do
for corner = 0, 3 do
local _, s, v, a = element:corner_color(corner)
element:set_corner_color(corner, h, s, v, a)
end
end
hue_select:set_pos(134, 2 + h*130)
sync_rgb_from_hsv()
end
local function hex_string_highlight(value)
for corner = 0, 3 do
local r, g, _, _ = hex_area:corner_color(corner)
hex_area:set_corner_color(corner, r, g, value, value)
end
end
local function set_saved(index, color)
state.saved[index] = {color[1], color[2], color[3], color[4]}
local slot = slots[index]
for corner = 0, 3 do
local _, _, _, a = slot:corner_color(corner)
slot:set_corner_color(corner, color[1], color[2], color[3], a)
end
end
-- Sync HSV+visuals from state.rgb; used after setting state.rgb directly.
local function apply_state_rgb()
state.hsv[1], state.hsv[2], state.hsv[3] =
ui.rgb_to_hsv(state.rgb[1], state.rgb[2], state.rgb[3])
hue_set(state.hsv[1])
sv_pick(state.hsv[2], state.hsv[3])
end
local function open_picker()
if picker_overlay then return end
picker_overlay = ui.create_overlay{anchor = ANCHOR_BOTTOM_LEFT, size = {190, 150}}
bg = ui.rect{
pos = {0, 0},
size = {190, 150},
color = {0.4, 0.4, 0.4, 0.8},
z = 0,
}
sv_square = ui.rect{
type = RECT_HSV,
pos = {2, 2},
size = {130, 130},
colors = {{0, 0, 1, 1}, {0, 1, 1, 1}, {0, 0, 0, 1}, {0, 1, 0, 1}},
events = EVENT_BUTTON,
z = 1,
}
hue_bar = ui.rect{
type = RECT_HSV,
pos = {134, 2},
size = {10, 130},
colors = {{0, 1, 1, 1}, {0, 1, 1, 1}, {1, 1, 1, 1}, {1, 1, 1, 1}},
events = EVENT_BUTTON + EVENT_SCROLL,
z = 1,
}
sv_outline = ui.rect{
pos = {130-4, 130-4},
size = {7, 7},
color = {0, 0, 0, 1},
z = 2,
}
sv_select = ui.rect{
type = RECT_HSV,
pos = {130-3, 130-3},
size = {5, 5},
color = {1, 0, 0, 1},
z = 3,
}
hex_area = ui.rect{
pos = {20, 134},
size = {95, 15},
events = EVENT_BUTTON + EVENT_CURSOR,
z = 1,
}
hue_select = ui.rect{
pos = {134, 2},
size = {10, 1},
color = {0, 0, 0, 1},
z = 2,
}
hex_text = ui.text{
pos = {2, 150},
size = 16,
color = {1, 1, 1, 1},
font = 0,
max_length = 16,
z = 2,
}
-- 12 saved color slots in a 2-wide grid
slots = {}
slot_index = {}
for i = 1, 12 do
local slot = ui.rect{
pos = {146 + ((i-1) % 2)*22, 2 + math.floor((i-1)/2)*22},
size = {20, 20},
color = {0, 0, 0, 1},
events = EVENT_BUTTON,
z = 1,
}
slots[i] = slot
slot_index[slot] = i
end
state = {
hsv = {0, 0, 0},
rgb = {0, 0, 0, 1},
saved = {},
hex_string = "#000000FF",
}
for i = 1, 12 do state.saved[i] = {0, 0, 0, 0} end
drag = {
[sv_square] = function(x, y) sv_pick(x, 1 - y) end,
[hue_bar] = function(x, y) hue_set(y) end,
}
-- adopt the current published color so reopening the picker doesn't
-- reset the selection
local published = app.get("editor.color")
if published then
for i = 1, 4 do
state.rgb[i] = (tonumber(published:sub(2*i, 2*i + 1), 16) or 0) / 255
end
end
apply_state_rgb()
end
local function close_picker()
if not picker_overlay then return end
picker_overlay:destroy()
picker_overlay = nil
bg, sv_square, hue_bar, sv_outline, sv_select = nil, nil, nil, nil, nil
hex_area, hue_select, hex_text, slots, slot_index, drag = nil, nil, nil, nil, nil, nil
state = nil
end
-- ============================== dispatch ================================
-- label_overlay has no handlers; every handler below only ever fires for
-- picker_overlay, but still checks since a shared script_env would call the
-- same on_button etc. for any future overlay this script owns.
function on_button(overlay, element, x, y, button, action, mods)
if overlay ~= picker_overlay then return false end
if drag[element] then
if action == PRESS and button == MOUSE_LEFT then
element:focus()
drag[element](x, y)
elseif action == RELEASE and button == MOUSE_LEFT then
ui.blur()
end
return true
elseif element == hex_area then
if action == PRESS and button == MOUSE_LEFT then
element:focus()
hex_string_highlight(1)
end
return true
elseif slot_index[element] then
local index = slot_index[element]
if action == PRESS then
if button == MOUSE_LEFT then
local saved = state.saved[index]
state.rgb = {saved[1], saved[2], saved[3], saved[4]}
apply_state_rgb()
elseif button == MOUSE_RIGHT then
set_saved(index, state.rgb)
elseif button == MOUSE_MIDDLE then
set_saved(index, {0, 0, 0, 0})
end
end
return true
end
return false
end
function on_cursor(overlay, element, x, y)
if overlay ~= picker_overlay then return false end
for el, fn in pairs(drag) do
if el:is_focused() then
fn(x, y)
return true
end
end
return false
end
function on_scroll(overlay, element, x, y)
if overlay ~= picker_overlay then return false end
if element == hue_bar then
hue_set(state.hsv[1] + y*0.01)
return true
end
return false
end
function on_key(overlay, element, key, action, mods)
if overlay ~= picker_overlay or element ~= hex_area then return false end
if action == PRESS then
if key == KEY_ESCAPE then
sync_rgb_from_hsv()
ui.blur()
elseif key == KEY_ENTER then
local s = state.hex_string
for i = 1, 4 do
state.rgb[i] = (tonumber(s:sub(2*i, 2*i + 1), 16) or 0) / 255
end
apply_state_rgb()
ui.blur()
elseif key == KEY_BACKSPACE then
if #state.hex_string > 1 then
state.hex_string = state.hex_string:sub(1, -2)
hex_text:set_text(state.hex_string)
end
end
end
return true
end
function on_text(overlay, element, codepoint)
if overlay ~= picker_overlay or element ~= hex_area then return false end
if codepoint < 128 then
local ch = string.char(codepoint):upper()
if ch:match("[0-9A-F]") and #state.hex_string < 9 then
state.hex_string = state.hex_string .. ch
hex_text:set_text(state.hex_string)
end
end
return true
end
function on_deselect(overlay, element)
if overlay ~= picker_overlay then return end
if element == hex_area then
hex_string_highlight(0)
end
end
-- ============================== mode tracking ============================
app.subscribe("engine.changed.editor.mode", function(source, mode)
label:set_text(mode)
if mode == "Vertex" or mode == "Neighbor" or mode == "Hex" then
open_picker()
else
close_picker()
end
end)

@ -1,16 +0,0 @@
-- Mode indicator label; the editor calls set_mode() on mode changes
local label = ui.text{
pos = {0, 32},
size = 32,
color = {1, 1, 1, 1},
font = 0,
max_length = 8,
z = 0,
}
function set_mode(name)
label:set_text(name)
end
set_mode("None")

@ -0,0 +1,4 @@
layout(std430, buffer_reference) readonly buffer Camera {
mat4 proj;
mat4 view;
};

@ -55,5 +55,5 @@ void main() {
} }
color = int2color(region.hexes[hex_index].colors[indices[gl_VertexIndex]]); color = int2color(region.hexes[hex_index].colors[indices[gl_VertexIndex]]);
gl_Position = pc.context.proj * pc.context.view * position; gl_Position = pc.camera.proj * pc.camera.view * position;
} }

@ -1,3 +1,5 @@
#include "camera_common.glsl"
struct Hex { struct Hex {
float heights[6]; float heights[6];
uint colors[7]; uint colors[7];
@ -46,8 +48,6 @@ layout(std430, buffer_reference) readonly buffer RayList {
}; };
layout(std430, buffer_reference) readonly buffer HexContext { layout(std430, buffer_reference) readonly buffer HexContext {
mat4 proj;
mat4 view;
uint current_map; uint current_map;
RayList rays; RayList rays;
PointList points; PointList points;
@ -57,6 +57,7 @@ layout(std430, buffer_reference) readonly buffer HexContext {
layout(std430, push_constant) uniform PushConstant { layout(std430, push_constant) uniform PushConstant {
HexContext context; HexContext context;
Camera camera;
float time; float time;
} pc; } pc;

@ -45,7 +45,7 @@ void main() {
position.y += raise; position.y += raise;
gl_Position = pc.context.proj * pc.context.view * position; gl_Position = pc.camera.proj * pc.camera.view * position;
} else { } else {
gl_Position = vec4(0, 0, 0, 0); gl_Position = vec4(0, 0, 0, 0);
color = vec4(0, 0, 0, 0); color = vec4(0, 0, 0, 0);

@ -36,7 +36,7 @@ void main() {
position.y = region.hexes[hex_index].heights[vertex_index-1] + region.y + raise; position.y = region.hexes[hex_index].heights[vertex_index-1] + region.y + raise;
gl_Position = pc.context.proj * pc.context.view * position; gl_Position = pc.camera.proj * pc.camera.view * position;
} else { } else {
gl_Position = vec4(0, 0, 0, 0); gl_Position = vec4(0, 0, 0, 0);
color = vec4(0, 0, 0, 0); color = vec4(0, 0, 0, 0);

@ -7,9 +7,9 @@ layout(location = 0) flat out vec4 color;
void main() { void main() {
if(gl_VertexIndex == 0) { if(gl_VertexIndex == 0) {
gl_Position = pc.context.proj * pc.context.view * pc.context.rays.r[gl_InstanceIndex].start; gl_Position = pc.camera.proj * pc.camera.view * pc.context.rays.r[gl_InstanceIndex].start;
} else { } else {
gl_Position = pc.context.proj * pc.context.view * pc.context.rays.r[gl_InstanceIndex].end; gl_Position = pc.camera.proj * pc.camera.view * pc.context.rays.r[gl_InstanceIndex].end;
} }
color = pc.context.rays.r[gl_InstanceIndex].color; color = pc.context.rays.r[gl_InstanceIndex].color;
} }

@ -0,0 +1,85 @@
#include "camera.h"
#include "ui.h"
#include <cglm/cam.h>
#include <math.h>
static vec3 up = {0, 1, 0};
VkResult create_camera(RenderContext* gpu, Camera* camera) {
VkResult result;
for(uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
VK_RESULT(create_storage_buffer(
gpu->allocator,
0,
sizeof(GPUCamera),
&camera->gpu_buffer[i],
&camera->gpu_buffer_memory[i]));
camera->gpu_address[i] = buffer_address(gpu->device, camera->gpu_buffer[i]);
}
return VK_SUCCESS;
}
void camera_update_view(Camera* camera) {
vec3 eye = {};
eye[0] = camera->position[0] + camera->distance*cos(camera->rotation[1])*cos(camera->rotation[0]);
eye[1] = camera->position[1] + camera->distance*sin(camera->rotation[1]);
eye[2] = camera->position[2] + camera->distance*cos(camera->rotation[1])*sin(camera->rotation[0]);
glm_lookat(eye, camera->position, up, camera->view);
}
void camera_update_proj(Camera* camera, float aspect) {
glm_perspective(
PERSPECTIVE_FOVY,
aspect,
PERSPECTIVE_NEARZ,
PERSPECTIVE_FARZ,
camera->proj);
}
VkResult camera_sync_gpu(Camera* camera, RenderContext* gpu) {
GPUCamera data;
glm_mat4_copy(camera->proj, data.proj);
glm_mat4_copy(camera->view, data.view);
return add_transfers(&data, camera->gpu_buffer, 0, sizeof(GPUCamera), gpu);
}
VkResult camera_init_texture_target(
RenderContext* gpu,
UIContext* ui,
Camera* camera,
uint32_t width,
uint32_t height) {
VkResult result;
camera->texture.width = width;
camera->texture.height = height;
VkExtent2D extent = {width, height};
for(uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
VK_RESULT(create_render_target_texture(gpu, ui, width, height, &camera->texture.texture_slot[i]));
VK_RESULT(create_depth_image(
gpu->device,
gpu->depth_format,
extent,
gpu->allocator,
gpu->extra_graphics_pool,
gpu->graphics_queue,
&camera->texture.depth_image[i],
&camera->texture.depth_image_memory[i],
&camera->texture.depth_image_view[i]));
}
camera->has_texture_target = true;
return VK_SUCCESS;
}
void camera_destroy_texture_target(RenderContext* gpu, UIContext* ui, Camera* camera) {
(void)ui;
for(uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
vkDestroyImageView(gpu->device, camera->texture.depth_image_view[i], NULL);
vmaDestroyImage(gpu->allocator, camera->texture.depth_image[i], camera->texture.depth_image_memory[i]);
}
camera->has_texture_target = false;
}

@ -2,27 +2,10 @@
#include "hex.h" #include "hex.h"
#include "vulkan/vulkan_core.h" #include "vulkan/vulkan_core.h"
void record_ui_draw(VkCommandBuffer command_buffer, UIContext* ui_context, double time, uint32_t frame) { void record_hex_draw(VkCommandBuffer command_buffer, HexContext* hex, VkDeviceAddress camera_address, double time, uint32_t frame) {
UIPushConstant push = {
.time = (float)time,
};
vkCmdBindPipeline(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_context->pipeline.pipeline);
vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_context->pipeline.layout, 0, 1, &ui_context->font_samplers, 0, NULL);
vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_context->pipeline.layout, 1, 1, &ui_context->font_textures, 0, NULL);
vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_context->pipeline.layout, 2, 1, &ui_context->samplers, 0, NULL);
vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui_context->pipeline.layout, 3, 1, &ui_context->textures, 0, NULL);
for(uint32_t o = 0; o < ui_context->container_order_count; o++) {
Container* c = &ui_context->containers[ui_context->container_order[o]];
push.container = c->address[frame];
vkCmdPushConstants(command_buffer, ui_context->pipeline.layout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, &push);
vkCmdDrawIndirect(command_buffer, c->container[frame], offsetof(GPUContainer, draw), 1, 0);
}
}
void record_hex_draw(VkCommandBuffer command_buffer, HexContext* hex, double time, uint32_t frame) {
HexPushConstant push = { HexPushConstant push = {
.context = hex->address[frame], .context = hex->address[frame],
.camera = camera_address,
.time = (float)time, .time = (float)time,
}; };
@ -41,6 +24,69 @@ void record_hex_draw(VkCommandBuffer command_buffer, HexContext* hex, double tim
vkCmdDraw(command_buffer, 2, 2, 0, 0); vkCmdDraw(command_buffer, 2, 2, 0, 0);
} }
static void bind_ui_pipeline(VkCommandBuffer command_buffer, UIContext* ui) {
vkCmdBindPipeline(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui->pipeline.pipeline);
vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui->pipeline.layout, 0, 1, &ui->font_samplers, 0, NULL);
vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui->pipeline.layout, 1, 1, &ui->font_textures, 0, NULL);
vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui->pipeline.layout, 2, 1, &ui->samplers, 0, NULL);
vkCmdBindDescriptorSets(command_buffer, VK_PIPELINE_BIND_POINT_GRAPHICS, ui->pipeline.layout, 3, 1, &ui->textures, 0, NULL);
}
// Single pass over container_order (back to front, same z-order UI has
// always used): a container with a camera attached renders that camera's
// hex scene scissored to its own region first, then every container
// (camera-attached or not) draws its normal 2D drawables via the UI
// pipeline on top - see draw_frame's single unified rendering scope, which
// is what makes interleaving these two pipelines per-container possible
// without a barrier between them.
void record_container_draw(
VkCommandBuffer command_buffer,
RenderContext* gpu,
UIContext* ui,
HexContext* hex,
VkViewport full_viewport,
VkRect2D full_scissor,
double time,
uint32_t frame) {
UIPushConstant push = {
.time = (float)time,
};
bind_ui_pipeline(command_buffer, ui);
for(uint32_t o = 0; o < ui->container_order_count; o++) {
Container* c = &ui->containers[ui->container_order[o]];
if(c->camera != NULL) {
VkRect2D rect;
container_screen_rect(gpu, c, &rect);
VkViewport camera_viewport = {
.x = (float)rect.offset.x,
.y = (float)rect.offset.y,
.width = (float)rect.extent.width,
.height = (float)rect.extent.height,
.minDepth = 0.0f,
.maxDepth = 1.0f,
};
vkCmdSetViewport(command_buffer, 0, 1, &camera_viewport);
vkCmdSetScissor(command_buffer, 0, 1, &rect);
record_hex_draw(command_buffer, hex, c->camera->gpu_address[frame], time, frame);
// record_hex_draw left a hex pipeline bound (a different bind point
// state) and the viewport/scissor scoped to this container - restore
// both before this (or the next) container's UI drawables.
vkCmdSetViewport(command_buffer, 0, 1, &full_viewport);
vkCmdSetScissor(command_buffer, 0, 1, &full_scissor);
bind_ui_pipeline(command_buffer, ui);
}
push.container = c->address[frame];
vkCmdPushConstants(command_buffer, ui->pipeline.layout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, &push);
vkCmdDrawIndirect(command_buffer, c->container[frame], offsetof(GPUContainer, draw), 1, 0);
}
}
void record_ui_compute(VkCommandBuffer command_buffer, UIContext* ui, uint32_t frame) { void record_ui_compute(VkCommandBuffer command_buffer, UIContext* ui, uint32_t frame) {
UIPushConstant push = { UIPushConstant push = {
.time = 0.0, .time = 0.0,
@ -61,6 +107,7 @@ VkResult draw_frame(
RenderContext* context, RenderContext* context,
UIContext* ui, UIContext* ui,
HexContext* hex, HexContext* hex,
Camera* offscreen_camera,
double time) { double time) {
VkResult result; VkResult result;
@ -170,26 +217,35 @@ VkResult draw_frame(
VK_RESULT(vkResetCommandBuffer(command_buffer, 0)); VK_RESULT(vkResetCommandBuffer(command_buffer, 0));
VK_RESULT(vkBeginCommandBuffer(command_buffer, &begin_info)); VK_RESULT(vkBeginCommandBuffer(command_buffer, &begin_info));
VkViewport viewport = {
.width = context->swapchain_extent.width,
.height = context->swapchain_extent.height,
.maxDepth = 1.0f,
.minDepth = 0.0f,
};
vkCmdSetViewport(command_buffer, 0, 1, &viewport);
VkRect2D scissor = {
.extent = context->swapchain_extent,
};
vkCmdSetScissor(command_buffer, 0, 1, &scissor);
VkImageSubresourceRange color_range = { VkImageSubresourceRange color_range = {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.levelCount = 1, .levelCount = 1,
.layerCount = 1, .layerCount = 1,
}; };
VkImageMemoryBarrier acquire_barrier = { // Targets a different image entirely (not the swapchain), so this can run
// any time after the command buffer begins - independent of the acquire
// below. The read barrier at the end is the producer-before-consumer sync
// that makes the texture safe for the unified scene pass (or anything
// else) to sample later this same frame.
if(offscreen_camera != NULL) {
uint32_t f = context->current_frame;
Texture* target = &ui->texture_slots[offscreen_camera->texture.texture_slot[f]];
VkViewport offscreen_viewport = {
.width = (float)offscreen_camera->texture.width,
.height = (float)offscreen_camera->texture.height,
.maxDepth = 1.0f,
.minDepth = 0.0f,
};
vkCmdSetViewport(command_buffer, 0, 1, &offscreen_viewport);
VkRect2D offscreen_scissor = {
.extent = {offscreen_camera->texture.width, offscreen_camera->texture.height},
};
vkCmdSetScissor(command_buffer, 0, 1, &offscreen_scissor);
VkImageMemoryBarrier offscreen_acquire_barrier = {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask = 0, .srcAccessMask = 0,
.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, .dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
@ -197,47 +253,77 @@ VkResult draw_frame(
.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, .newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = context->swapchain_images[image_index], .image = target->image,
.subresourceRange = color_range, .subresourceRange = color_range,
}; };
vkCmdPipelineBarrier(command_buffer, vkCmdPipelineBarrier(command_buffer,
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
0, 0, NULL, 0, NULL, 1, &acquire_barrier); 0, 0, NULL, 0, NULL, 1, &offscreen_acquire_barrier);
VkRenderingAttachmentInfo color_attachment = { VkRenderingAttachmentInfo offscreen_color_attachment = {
.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO, .sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO,
.imageView = context->swapchain_image_views[image_index], .imageView = target->view,
.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, .imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR, .loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR,
.storeOp = VK_ATTACHMENT_STORE_OP_STORE, .storeOp = VK_ATTACHMENT_STORE_OP_STORE,
.clearValue = {.color = {{0.0f, 0.0f, 0.0f, 0.0f}}}, .clearValue = {.color = {{0.0f, 0.0f, 0.0f, 0.0f}}},
}; };
VkRenderingAttachmentInfo depth_attachment = { VkRenderingAttachmentInfo offscreen_depth_attachment = {
.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO, .sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO,
.imageView = context->depth_image_view, .imageView = offscreen_camera->texture.depth_image_view[f],
.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL, .imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR, .loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR,
.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE, .storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE,
.clearValue = {.depthStencil = {1.0f, 0}}, .clearValue = {.depthStencil = {1.0f, 0}},
}; };
VkRenderingInfo hex_rendering = { VkRenderingInfo offscreen_rendering = {
.sType = VK_STRUCTURE_TYPE_RENDERING_INFO, .sType = VK_STRUCTURE_TYPE_RENDERING_INFO,
.renderArea = {{0, 0}, context->swapchain_extent}, .renderArea = {{0, 0}, offscreen_scissor.extent},
.layerCount = 1, .layerCount = 1,
.colorAttachmentCount = 1, .colorAttachmentCount = 1,
.pColorAttachments = &color_attachment, .pColorAttachments = &offscreen_color_attachment,
.pDepthAttachment = &depth_attachment, .pDepthAttachment = &offscreen_depth_attachment,
}; };
vkCmdBeginRendering(command_buffer, &hex_rendering); vkCmdBeginRendering(command_buffer, &offscreen_rendering);
record_hex_draw(command_buffer, hex, time, context->current_frame); record_hex_draw(command_buffer, hex, offscreen_camera->gpu_address[f], time, f);
vkCmdEndRendering(command_buffer); vkCmdEndRendering(command_buffer);
VkImageMemoryBarrier mid_barrier = { VkImageMemoryBarrier offscreen_read_barrier = {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, .sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, .srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, .dstAccessMask = VK_ACCESS_SHADER_READ_BIT,
.oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, .oldLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = target->image,
.subresourceRange = color_range,
};
vkCmdPipelineBarrier(command_buffer,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
0, 0, NULL, 0, NULL, 1, &offscreen_read_barrier);
}
VkViewport viewport = {
.width = context->swapchain_extent.width,
.height = context->swapchain_extent.height,
.maxDepth = 1.0f,
.minDepth = 0.0f,
};
vkCmdSetViewport(command_buffer, 0, 1, &viewport);
VkRect2D scissor = {
.extent = context->swapchain_extent,
};
vkCmdSetScissor(command_buffer, 0, 1, &scissor);
VkImageMemoryBarrier acquire_barrier = {
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.srcAccessMask = 0,
.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED,
.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, .newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED, .dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
@ -245,26 +331,36 @@ VkResult draw_frame(
.subresourceRange = color_range, .subresourceRange = color_range,
}; };
vkCmdPipelineBarrier(command_buffer, vkCmdPipelineBarrier(command_buffer,
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, 0, 0, NULL, 0, NULL, 1, &acquire_barrier);
0, 0, NULL, 0, NULL, 1, &mid_barrier);
VkRenderingAttachmentInfo ui_color_attachment = { VkRenderingAttachmentInfo color_attachment = {
.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO, .sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO,
.imageView = context->swapchain_image_views[image_index], .imageView = context->swapchain_image_views[image_index],
.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, .imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,
.loadOp = VK_ATTACHMENT_LOAD_OP_LOAD, .loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR,
.storeOp = VK_ATTACHMENT_STORE_OP_STORE, .storeOp = VK_ATTACHMENT_STORE_OP_STORE,
.clearValue = {.color = {{0.0f, 0.0f, 0.0f, 0.0f}}},
}; };
VkRenderingInfo ui_rendering = { VkRenderingAttachmentInfo depth_attachment = {
.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO,
.imageView = context->depth_image_view,
.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL,
.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR,
.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE,
.clearValue = {.depthStencil = {1.0f, 0}},
};
VkRenderingInfo scene_rendering = {
.sType = VK_STRUCTURE_TYPE_RENDERING_INFO, .sType = VK_STRUCTURE_TYPE_RENDERING_INFO,
.renderArea = {{0, 0}, context->swapchain_extent}, .renderArea = {{0, 0}, context->swapchain_extent},
.layerCount = 1, .layerCount = 1,
.colorAttachmentCount = 1, .colorAttachmentCount = 1,
.pColorAttachments = &ui_color_attachment, .pColorAttachments = &color_attachment,
.pDepthAttachment = &depth_attachment,
}; };
vkCmdBeginRendering(command_buffer, &ui_rendering); vkCmdBeginRendering(command_buffer, &scene_rendering);
record_ui_draw(command_buffer, ui, time, context->current_frame); record_container_draw(command_buffer, context, ui, hex, viewport, scissor, time, context->current_frame);
vkCmdEndRendering(command_buffer); vkCmdEndRendering(command_buffer);
VkImageMemoryBarrier present_barrier = { VkImageMemoryBarrier present_barrier = {

@ -2,19 +2,29 @@
#include "hex.h" #include "hex.h"
#include "engine.h" #include "engine.h"
#include "editor.h" #include "editor.h"
#include "events.h"
#include "ui_lua.h" #include "ui_lua.h"
#include "editor_lua.h"
#include "vulkan/vulkan_core.h" #include "vulkan/vulkan_core.h"
#include <math.h> #include <math.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h>
#define COLOR_PICK_CONTAINER_ID 0x02
#define MODE_STRING_CONTAINER_ID 0x01
#define SELECTION_HIGHLIGHT_OFFSET 0.05f #define SELECTION_HIGHLIGHT_OFFSET 0.05f
#define SELECTION_POINT_SIZE 12.0f #define SELECTION_POINT_SIZE 12.0f
#define SELECTION_POINT_OFFSET 0.05f #define SELECTION_POINT_OFFSET 0.05f
// Hover preview uses the last (reserved) highlight/point slot, a slightly
// higher offset so it never z-fights with a selected element underneath,
// and a translucent color that switches between "would add" and "would
// remove" depending on whether the hovered element is already selected.
#define HOVER_HIGHLIGHT_OFFSET 0.07f
#define HOVER_POINT_SIZE 16.0f
#define HOVER_POINT_OFFSET 0.07f
#define HOVER_ADD_COLOR {0.2f, 0.85f, 1.0f, 0.45f}
#define HOVER_REMOVE_COLOR {1.0f, 0.2f, 0.2f, 0.55f}
const char* ModeStrings[] = { const char* ModeStrings[] = {
"None", "None",
"Vertex", "Vertex",
@ -93,58 +103,106 @@ uint32_t add_hex_region(ClientContext* context) {
return i; return i;
} }
VkResult color_ui(ClientContext* context) { // Movement/spin keys are only ever seen by editor.c when no UI element has
if(context_container(COLOR_PICK_CONTAINER_ID, &context->ui) != NULL) { // focus (engine.c routes key events to the focused element first). If focus
return VK_SUCCESS; // is gained while a key is held, its release never reaches us and the
// accumulator in move_cam/spin_cam is left stuck. Zeroing here every frame a
// UI element is focused keeps the camera from drifting forever in that case.
//
// Integrates the editor's WASD/scroll control scheme into the engine-owned
// context->camera. The orbital parameterization itself lives on Camera; this
// is just the policy of how held input maps to changes in it, so a scripted
// sequence or a different app could drive the same camera without any of
// this accumulator machinery.
void editor_frame_callback(ClientContext* context, double delta_time) {
EditorData* data = context->app_data;
Camera* camera = &context->camera;
// Which cameras/containers track the window size is app policy (engine.c
// only handles UI's own screen-space scale) - the main view is the one
// built-in camera, kept full-window in this pass (see
// cursor_to_world_ray's full-window assumption in hex.c).
if(context->render.framebuffer_recreated) {
data->main_container->data.size[0] = context->render.swapchain_extent.width / context->render.window_scale[0];
data->main_container->data.size[1] = context->render.swapchain_extent.height / context->render.window_scale[1];
add_transfers(
&data->main_container->data.size,
data->main_container->container,
offsetof(GPUContainer, size),
sizeof(vec2),
&context->render);
camera_update_proj(camera, (float)context->render.swapchain_extent.width/(float)context->render.swapchain_extent.height);
camera_sync_gpu(camera, &context->render);
update_hex_picking_inverse(camera, &context->hex);
} }
ContainerInput container = { if(context->ui.active_container != NULL) {
.anchor = ANCHOR_BOTTOM_LEFT, data->velocity[0] = 0;
.id = COLOR_PICK_CONTAINER_ID, data->velocity[1] = 0;
.offset = {0, 0}, data->velocity[2] = 0;
.size = {190, 150}, data->spin[0] = 0;
.script_path = "script/color_picker.lua", data->spin[1] = 0;
}; }
return load_container(&container, &context->render, &context->ui); if(data->spin[0] != 0 || data->spin[1] != 0 ||
} data->velocity[0] != 0 || data->velocity[1] != 0 || data->velocity[2] != 0 ||
data->zoom != 0) {
VkResult mode_string_ui(ClientContext* context) { camera->rotation[0] += (float)data->spin[0]*delta_time*data->spin_speed;
ContainerInput container = { if(camera->rotation[0] > 2*M_PI) {
.id = MODE_STRING_CONTAINER_ID, camera->rotation[0] -= 2*M_PI;
.anchor = ANCHOR_TOP_LEFT, } else if(camera->rotation[0] < 0) {
.size = {160, 40}, camera->rotation[0] += 2*M_PI;
.script_path = "script/mode_string.lua", }
};
return load_container(&container, &context->render, &context->ui); camera->rotation[1] += (float)data->spin[1]*delta_time*data->spin_speed;
} if(camera->rotation[1] > (M_PI/2 - 0.1)) {
camera->rotation[1] = (M_PI/2 - 0.1);
} else if(camera->rotation[1] < 0) {
camera->rotation[1] = 0;
}
VkResult update_mode_string(ClientContext* context, EditorData* data) { float move_x = data->velocity[0];
Container* container = context_container(MODE_STRING_CONTAINER_ID, &context->ui); float move_z = data->velocity[2];
return ui_lua_call(&context->ui, &context->render, container, "set_mode", ModeStrings[data->mode]); float move_mag = sqrt(move_x*move_x + move_z*move_z);
} if(move_mag > 1) {
move_x /= move_mag;
move_z /= move_mag;
}
// Movement/spin keys are only ever seen by editor.c when no UI element has camera->position[0] += - move_z*data->move_speed*cos(camera->rotation[0])
// focus (engine.c routes key events to the focused element first). If focus - move_x*data->move_speed*sin(camera->rotation[0]);
// is gained while a key is held, its release never reaches us and the
// accumulator in move_cam/spin_cam is left stuck. Zeroing here every frame a camera->position[2] += move_x*data->move_speed*cos(camera->rotation[0])
// UI element is focused keeps the camera from drifting forever in that case. - move_z*data->move_speed*sin(camera->rotation[0]);
void editor_frame_callback(ClientContext* context) {
if(context->ui.active_container != NULL) { camera->position[1] += data->velocity[1]*data->move_speed;
context->velocity[0] = 0;
context->velocity[1] = 0; // data->zoom is a one-frame scroll impulse, not a held input like
context->velocity[2] = 0; // spin/velocity, so it isn't scaled by delta_time.
context->spin[0] = 0; camera->distance += data->zoom*data->zoom_speed;
context->spin[1] = 0; if(camera->distance < 1) {
camera->distance = 1;
}
camera_update_view(camera);
camera_sync_gpu(camera, &context->render);
update_hex_picking_inverse(camera, &context->hex);
} }
// Consumed above; reset after (not at the top, where it would wipe this
// frame's scroll_callback input, which fires during glfwPollEvents,
// before this callback runs).
data->zoom = 0;
} }
void editor_key_callback(ClientContext* context, int key, int action, int mods) { void editor_key_callback(ClientContext* context, int key, int action, int mods) {
EditorData* data = context->app_data; EditorData* data = context->app_data;
for(uint32_t i = 0; i < data->mode_key_counts[data->mode]; i++) { EditorMode mode = current_mode(context);
if(data->mode_keys[data->mode][i].key == key) { for(uint32_t i = 0; i < data->mode_key_counts[mode]; i++) {
data->mode_keys[data->mode][i].logic(data, context, &data->mode_keys[data->mode][i], action, mods); if(data->mode_keys[mode][i].key == key) {
data->mode_keys[mode][i].logic(data, context, &data->mode_keys[mode][i], action, mods);
return; return;
} }
} }
@ -158,22 +216,23 @@ void editor_key_callback(ClientContext* context, int key, int action, int mods)
} }
void spin_cam_key(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { void spin_cam_key(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) {
(void)data; (void)context;
(void)mods; (void)mods;
if(action == GLFW_PRESS) context->spin[binding->axis] += binding->amount; if(action == GLFW_PRESS) data->spin[binding->axis] += binding->amount;
else if(action == GLFW_RELEASE) context->spin[binding->axis] -= binding->amount; else if(action == GLFW_RELEASE) data->spin[binding->axis] -= binding->amount;
} }
void move_cam_key(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { void move_cam_key(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) {
(void)data; (void)context;
(void)mods; (void)mods;
if(action == GLFW_PRESS) context->velocity[binding->axis] += binding->amount; if(action == GLFW_PRESS) data->velocity[binding->axis] += binding->amount;
else if(action == GLFW_RELEASE) context->velocity[binding->axis] -= binding->amount; else if(action == GLFW_RELEASE) data->velocity[binding->axis] -= binding->amount;
} }
void editor_scroll_callback(ClientContext* context, double x, double y) { void editor_scroll_callback(ClientContext* context, double x, double y) {
(void)x; (void)x;
context->zoom = (int32_t)y; EditorData* data = context->app_data;
data->zoom = (int32_t)y;
} }
void resize_selected(EditorData* data, unsigned int size) { void resize_selected(EditorData* data, unsigned int size) {
@ -249,8 +308,10 @@ bool editor_pick(ClientContext* context, double cursor[2], uint32_t* rid, uint32
return ray_world_intersect(&distance, vertex, rid, hid, start, end, edge_only, &context->hex); return ray_world_intersect(&distance, vertex, rid, hid, start, end, edge_only, &context->hex);
} }
// Selection uses all highlight/point slots except the last, which is
// reserved for the hover preview (see refresh_hover_visuals).
void sync_hex_highlights(EditorData* data, ClientContext* context) { void sync_hex_highlights(EditorData* data, ClientContext* context) {
for(uint32_t i = 0; i < MAX_HIGHLIGHTS; i++) { for(uint32_t i = 0; i < MAX_HIGHLIGHTS - 1; i++) {
if(i < data->selected_count) { if(i < data->selected_count) {
GPUHighlight temp = { GPUHighlight temp = {
.color = {1.0f, 0.85f, 0.05f, 1.0f}, .color = {1.0f, 0.85f, 0.05f, 1.0f},
@ -267,7 +328,7 @@ void sync_hex_highlights(EditorData* data, ClientContext* context) {
} }
void sync_vertex_points(EditorData* data, ClientContext* context) { void sync_vertex_points(EditorData* data, ClientContext* context) {
for(uint32_t i = 0; i < MAX_POINTS; i++) { for(uint32_t i = 0; i < MAX_POINTS - 1; i++) {
if(i < data->selected_count) { if(i < data->selected_count) {
GPUPoint temp = { GPUPoint temp = {
.color = {1.0f, 0.85f, 0.05f, 1.0f}, .color = {1.0f, 0.85f, 0.05f, 1.0f},
@ -285,87 +346,115 @@ void sync_vertex_points(EditorData* data, ClientContext* context) {
} }
} }
void refresh_selection_visuals(EditorData* data, ClientContext* context) { // Writes (or disables) the reserved hover slot. Color depends on whether
sync_hex_highlights(data, context); // the hovered element is already selected, so a click's effect (add vs.
sync_vertex_points(data, context); // remove) is visible before the user commits to it.
} void refresh_hover_visuals(EditorData* data, ClientContext* context) {
EditorMode mode = current_mode(context);
uint32_t highlight_slot = MAX_HIGHLIGHTS - 1;
// In vertex mode the hex highlight is just spatial context for the
// hovered vertex, so it mirrors the vertex's own add/remove color rather
// than tracking hex selection (vertex mode doesn't select whole hexes).
if((mode == MODE_HEX || mode == MODE_VERTEX) && data->hover_valid) {
bool would_remove = (mode == MODE_HEX)
? find_selected_hex(data, data->hover_region, data->hover_hex) != -1
: find_selected_vertex(data, data->hover_region, data->hover_hex, data->hover_vertex) != -1;
GPUHighlight temp;
if(would_remove) temp = (GPUHighlight){.color = HOVER_REMOVE_COLOR};
else temp = (GPUHighlight){.color = HOVER_ADD_COLOR};
temp.region = data->hover_region;
temp.hex = data->hover_hex;
temp.offset = HOVER_HIGHLIGHT_OFFSET;
add_transfers(&temp, context->hex.highlights, sizeof(GPUHighlight)*highlight_slot, sizeof(GPUHighlight), &context->render);
} else {
uint32_t disabled = 0xFFFFFFFF;
add_transfers(&disabled, context->hex.highlights, sizeof(GPUHighlight)*highlight_slot + offsetof(GPUHighlight, hex), sizeof(uint32_t), &context->render);
}
void clear_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { uint32_t point_slot = MAX_POINTS - 1;
(void)binding; if(mode == MODE_VERTEX && data->hover_valid) {
(void)mods; bool would_remove = find_selected_vertex(data, data->hover_region, data->hover_hex, data->hover_vertex) != -1;
if(action == GLFW_PRESS) { GPUPoint temp;
data->mode = MODE_NONE; if(would_remove) temp = (GPUPoint){.color = HOVER_REMOVE_COLOR};
update_mode_string(context, data); else temp = (GPUPoint){.color = HOVER_ADD_COLOR};
data->selected_count = 0; temp.region = data->hover_region;
refresh_selection_visuals(data, context); temp.hex = data->hover_hex;
unload_container(COLOR_PICK_CONTAINER_ID, &context->render, &context->ui); temp.vertex = data->hover_vertex;
temp.size = HOVER_POINT_SIZE;
temp.offset = HOVER_POINT_OFFSET;
add_transfers(&temp, context->hex.points, sizeof(GPUPoint)*point_slot, sizeof(GPUPoint), &context->render);
} else {
uint32_t disabled = 0xFFFFFFFF;
add_transfers(&disabled, context->hex.points, sizeof(GPUPoint)*point_slot + offsetof(GPUPoint, hex), sizeof(uint32_t), &context->render);
} }
} }
void enter_vertex_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { void refresh_selection_visuals(EditorData* data, ClientContext* context) {
(void)binding; sync_hex_highlights(data, context);
(void)mods; sync_vertex_points(data, context);
if(action == GLFW_PRESS) { refresh_hover_visuals(data, context);
data->mode = MODE_VERTEX;
update_mode_string(context, data);
data->selected_count = 0;
refresh_selection_visuals(data, context);
color_ui(context);
}
} }
void enter_neighbor_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { // editor.mode is the single source of truth (a property on the event bus);
(void)binding; // nothing stores a separate copy. Reads are synchronous and cheap (a strcmp
(void)mods; // loop over MODE_MAX_ENUM short strings) so there's nothing worth caching.
if(action == GLFW_PRESS) { EditorMode current_mode(ClientContext* context) {
data->mode = MODE_NEIGHBOR; const char* name = event_property(&context->events, "editor.mode")->value.string;
update_mode_string(context, data); for(int i = 0; i < MODE_MAX_ENUM; i++) {
data->selected_count = 0; if(strcmp(ModeStrings[i], name) == 0) return (EditorMode)i;
refresh_selection_visuals(data, context);
color_ui(context);
} }
return MODE_NONE;
} }
void enter_hex_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { // The only C-side way to change modes; script/editor_mode.lua changes modes
(void)binding; // by writing the same property directly via app.set instead. Either path
(void)mods; // queues engine.changed.editor.mode, which on_mode_changed below reacts to
if(action == GLFW_PRESS) { // uniformly regardless of which path triggered it.
data->mode = MODE_HEX; void editor_set_mode(ClientContext* context, EditorMode mode) {
update_mode_string(context, data); event_property_set_string(&context->events, "editor.mode", ModeStrings[mode], EVENT_SOURCE_ENGINE);
data->selected_count = 0;
refresh_selection_visuals(data, context);
color_ui(context);
}
} }
void enter_region_mode(EditorData* data, ClientContext* context, ModeKey* binding, int action, int mods) { // Selection/hover don't carry across modes; this is the only place that
(void)binding; // resets them, so it runs the same whether the mode change came from C or
(void)mods; // from a script's app.set.
if(action == GLFW_PRESS) { static void on_mode_changed(void* userdata, const char* event, uint32_t source, lua_State* L, int args) {
data->mode = MODE_REGION; (void)event;
update_mode_string(context, data); (void)source;
(void)L;
(void)args;
ClientContext* context = userdata;
EditorData* data = context->app_data;
data->selected_count = 0; data->selected_count = 0;
data->hover_valid = false;
refresh_selection_visuals(data, context); refresh_selection_visuals(data, context);
unload_container(COLOR_PICK_CONTAINER_ID, &context->render, &context->ui);
}
} }
void editor_button_callback(ClientContext* context, float x, float y, int button, int action, int mods) { void editor_button_callback(ClientContext* context, float x, float y, int button, int action, int mods) {
EditorData* data = context->app_data; EditorData* data = context->app_data;
EditorMode mode = current_mode(context);
if(button != GLFW_MOUSE_BUTTON_LEFT || action != GLFW_PRESS) return; if(button != GLFW_MOUSE_BUTTON_LEFT || action != GLFW_PRESS) return;
if(data->mode != MODE_VERTEX && data->mode != MODE_HEX) return; if(mode != MODE_VERTEX && mode != MODE_HEX) return;
uint32_t rid, hid, v;
if(data->hover_valid) {
// editor_cursor_callback already raycast this exact spot for the hover
// preview; the cursor can't have moved between that and this click, so
// reuse it instead of raycasting again.
rid = data->hover_region;
hid = data->hover_hex;
v = data->hover_vertex;
} else {
double cursor[2] = {x, y}; double cursor[2] = {x, y};
uint32_t rid, hid, vertex; uint32_t vertex;
// Vertex mode requests edge-only picking so a click always resolves to a // Vertex mode requests edge-only picking so a click always resolves to a
// real, editable vertex instead of frequently landing on the hex center // real, editable vertex instead of frequently landing on the hex center
// (vertex 0) and giving no feedback. // (vertex 0) and giving no feedback.
bool hit = editor_pick(context, cursor, &rid, &hid, &vertex, data->mode == MODE_VERTEX); if(!editor_pick(context, cursor, &rid, &hid, &vertex, mode == MODE_VERTEX)) return;
if(!hit) return; v = (mode == MODE_VERTEX) ? vertex : 0;
}
uint32_t v = (data->mode == MODE_VERTEX) ? vertex : 0; int32_t idx = (mode == MODE_VERTEX)
int32_t idx = (data->mode == MODE_VERTEX)
? find_selected_vertex(data, rid, hid, v) ? find_selected_vertex(data, rid, hid, v)
: find_selected_hex(data, rid, hid); : find_selected_hex(data, rid, hid);
@ -381,8 +470,91 @@ void editor_button_callback(ClientContext* context, float x, float y, int button
refresh_selection_visuals(data, context); refresh_selection_visuals(data, context);
} }
void editor_cursor_callback(ClientContext* context, float x, float y) {
EditorData* data = context->app_data;
EditorMode mode = current_mode(context);
if(mode != MODE_VERTEX && mode != MODE_HEX) {
if(data->hover_valid) {
data->hover_valid = false;
refresh_hover_visuals(data, context);
}
return;
}
double cursor[2] = {x, y};
uint32_t rid, hid, vertex;
bool hit = editor_pick(context, cursor, &rid, &hid, &vertex, mode == MODE_VERTEX);
uint32_t v = (mode == MODE_VERTEX) ? vertex : 0;
// Skip the GPU write unless the hovered element actually changed; cursor
// motion fires far more often than the hover target changes.
bool changed = hit != data->hover_valid
|| (hit && (data->hover_region != rid || data->hover_hex != hid
|| (mode == MODE_VERTEX && data->hover_vertex != v)));
if(!changed) return;
data->hover_valid = hit;
if(hit) {
data->hover_region = rid;
data->hover_hex = hid;
data->hover_vertex = v;
}
refresh_hover_visuals(data, context);
}
void editor_startup(ClientContext* context) { void editor_startup(ClientContext* context) {
mode_string_ui(context); EditorData* data = context->app_data;
editor_lua_register(context->ui.lua, context);
// Application state surface visible to scripts; registered before any
// container loads so app.get sees the initial values
event_property_register(&context->events, "editor.mode", PROPERTY_STRING);
event_property_register(&context->events, "editor.color", PROPERTY_STRING);
event_property_set_string(&context->events, "editor.mode", ModeStrings[MODE_NONE], EVENT_SOURCE_ENGINE);
event_property_set_string(&context->events, "editor.color", "#000000FF", EVENT_SOURCE_ENGINE);
// Registered before any script loads so it's guaranteed to see mode
// changes editor_mode.lua triggers, ahead of editor_ui.lua's own reaction
event_subscribe(&context->events, "engine.changed.editor.mode", on_mode_changed, context);
// Controller script: owns the mode label and the color picker overlay,
// both driven entirely by the editor.mode property from here on
ui_lua_run_script(context->ui.lua, &context->ui, &context->render, "script/editor_ui.lua");
// Input->state-machine trigger: writes editor.mode on V/N/H/R/Escape
ui_lua_run_script(context->ui.lua, &context->ui, &context->render, "script/editor_mode.lua");
// Main 3D view: a full-window container hosting the interactive camera,
// same as every other overlay - see container_set_camera (ui.h). Screen-
// region targeting lives on the container, not the camera, so this is
// the only place that needs to know the camera renders full-window.
ContainerInput main_view = {
.id = ui_alloc_container_id(&context->ui),
.anchor = ANCHOR_TOP_LEFT,
.offset = {0, 0},
.size = {
context->render.swapchain_extent.width / context->render.window_scale[0],
context->render.swapchain_extent.height / context->render.window_scale[1],
},
};
load_container(&main_view, &context->render, &context->ui);
data->main_container = context_container(main_view.id, &context->ui);
container_set_camera(data->main_container, &context->camera);
// The editor sets the camera's initial values and computes its first
// view/projection here; editor_frame_callback only recomputes them when
// spin/velocity/zoom (or a resize) happen, so without this the initial
// view stays whatever ClientContext's zero-init left it as until the
// camera is first moved.
context->camera.rotation[0] = 3*M_PI/2;
context->camera.rotation[1] = M_PI/4;
context->camera.distance = 25;
camera_update_view(&context->camera);
camera_update_proj(&context->camera, (float)context->render.swapchain_extent.width/(float)context->render.swapchain_extent.height);
camera_sync_gpu(&context->camera, &context->render);
update_hex_picking_inverse(&context->camera, &context->hex);
// TODO: Remove when region mode is implemented // TODO: Remove when region mode is implemented
add_hex_region(context); add_hex_region(context);
} }
@ -392,32 +564,30 @@ EditorData* create_editor_data(void) {
memset(data, 0, sizeof(EditorData)); memset(data, 0, sizeof(EditorData));
data->selected_count = 0; data->selected_count = 0;
resize_selected(data, 1); resize_selected(data, 1);
data->spin_speed = 1.0;
data->zoom_speed = 0.5;
data->move_speed = 0.1;
data->mode_key_counts[MODE_NONE] = 15; // Mode-switch keys (Escape/V/N/H/R) moved to script/editor_mode.lua,
// triggered by the engine.key event instead of this table
data->mode_key_counts[MODE_NONE] = 10;
for(int i = 0; i < MODE_MAX_ENUM; i++) { for(int i = 0; i < MODE_MAX_ENUM; i++) {
data->mode_keys[i] = malloc(sizeof(ModeKey)*data->mode_key_counts[i]); data->mode_keys[i] = malloc(sizeof(ModeKey)*data->mode_key_counts[i]);
} }
// Mode Switches
data->mode_keys[MODE_NONE][0] = (ModeKey){GLFW_KEY_ESCAPE, clear_mode, 0, 0};
data->mode_keys[MODE_NONE][1] = (ModeKey){GLFW_KEY_V, enter_vertex_mode, 0, 0};
data->mode_keys[MODE_NONE][2] = (ModeKey){GLFW_KEY_N, enter_neighbor_mode, 0, 0};
data->mode_keys[MODE_NONE][3] = (ModeKey){GLFW_KEY_H, enter_hex_mode, 0, 0};
data->mode_keys[MODE_NONE][4] = (ModeKey){GLFW_KEY_R, enter_region_mode, 0, 0};
// Camera Movement (axis: 0 = strafe x, 1 = up/down, 2 = forward/back) // Camera Movement (axis: 0 = strafe x, 1 = up/down, 2 = forward/back)
data->mode_keys[MODE_NONE][5] = (ModeKey){GLFW_KEY_SPACE, move_cam_key, 1, 1}; data->mode_keys[MODE_NONE][0] = (ModeKey){GLFW_KEY_SPACE, move_cam_key, 1, 1};
data->mode_keys[MODE_NONE][6] = (ModeKey){GLFW_KEY_LEFT_SHIFT, move_cam_key, 1, -1}; data->mode_keys[MODE_NONE][1] = (ModeKey){GLFW_KEY_LEFT_SHIFT, move_cam_key, 1, -1};
data->mode_keys[MODE_NONE][7] = (ModeKey){GLFW_KEY_LEFT, move_cam_key, 0, -1}; data->mode_keys[MODE_NONE][2] = (ModeKey){GLFW_KEY_LEFT, move_cam_key, 0, -1};
data->mode_keys[MODE_NONE][8] = (ModeKey){GLFW_KEY_RIGHT, move_cam_key, 0, 1}; data->mode_keys[MODE_NONE][3] = (ModeKey){GLFW_KEY_RIGHT, move_cam_key, 0, 1};
data->mode_keys[MODE_NONE][9] = (ModeKey){GLFW_KEY_UP, move_cam_key, 2, 1}; data->mode_keys[MODE_NONE][4] = (ModeKey){GLFW_KEY_UP, move_cam_key, 2, 1};
data->mode_keys[MODE_NONE][10] = (ModeKey){GLFW_KEY_DOWN, move_cam_key, 2, -1}; data->mode_keys[MODE_NONE][5] = (ModeKey){GLFW_KEY_DOWN, move_cam_key, 2, -1};
// Camera Spin (axis: 0 = yaw, 1 = pitch) // Camera Spin (axis: 0 = yaw, 1 = pitch)
data->mode_keys[MODE_NONE][11] = (ModeKey){GLFW_KEY_A, spin_cam_key, 0, -1}; data->mode_keys[MODE_NONE][6] = (ModeKey){GLFW_KEY_A, spin_cam_key, 0, -1};
data->mode_keys[MODE_NONE][12] = (ModeKey){GLFW_KEY_D, spin_cam_key, 0, 1}; data->mode_keys[MODE_NONE][7] = (ModeKey){GLFW_KEY_D, spin_cam_key, 0, 1};
data->mode_keys[MODE_NONE][13] = (ModeKey){GLFW_KEY_W, spin_cam_key, 1, 1}; data->mode_keys[MODE_NONE][8] = (ModeKey){GLFW_KEY_W, spin_cam_key, 1, 1};
data->mode_keys[MODE_NONE][14] = (ModeKey){GLFW_KEY_S, spin_cam_key, 1, -1}; data->mode_keys[MODE_NONE][9] = (ModeKey){GLFW_KEY_S, spin_cam_key, 1, -1};
return data; return data;
} }

@ -0,0 +1,89 @@
#include "editor_lua.h"
#define EDITOR_LUA_CONTEXT "editor_context"
// ClientContext lives once, heap-allocated for the process, never moves —
// unlike ui_lua.c's per-dispatch container/gpu pointers, one registry slot
// is enough here.
static ClientContext* current_context(lua_State* L) {
lua_getfield(L, LUA_REGISTRYINDEX, EDITOR_LUA_CONTEXT);
ClientContext* context = lua_touserdata(L, -1);
lua_pop(L, 1);
return context;
}
// Recomputes the camera's view and pushes it to the currently-rendered hex
// scene. Not gated behind any per-frame check - a script-driven camera move
// with no held input must be visible immediately.
static void refresh_camera(ClientContext* context) {
camera_update_view(&context->camera);
camera_sync_gpu(&context->camera, &context->render);
update_hex_picking_inverse(&context->camera, &context->hex);
}
// camera.set_position(x, y, z)
static int lua_camera_set_position(lua_State* L) {
ClientContext* context = current_context(L);
context->camera.position[0] = luaL_checknumber(L, 1);
context->camera.position[1] = luaL_checknumber(L, 2);
context->camera.position[2] = luaL_checknumber(L, 3);
refresh_camera(context);
return 0;
}
// camera.get_position() -> x, y, z
static int lua_camera_get_position(lua_State* L) {
Camera* camera = &current_context(L)->camera;
lua_pushnumber(L, camera->position[0]);
lua_pushnumber(L, camera->position[1]);
lua_pushnumber(L, camera->position[2]);
return 3;
}
// camera.set_rotation(yaw, pitch)
static int lua_camera_set_rotation(lua_State* L) {
ClientContext* context = current_context(L);
context->camera.rotation[0] = luaL_checknumber(L, 1);
context->camera.rotation[1] = luaL_checknumber(L, 2);
refresh_camera(context);
return 0;
}
// camera.get_rotation() -> yaw, pitch
static int lua_camera_get_rotation(lua_State* L) {
Camera* camera = &current_context(L)->camera;
lua_pushnumber(L, camera->rotation[0]);
lua_pushnumber(L, camera->rotation[1]);
return 2;
}
// camera.set_distance(d)
static int lua_camera_set_distance(lua_State* L) {
ClientContext* context = current_context(L);
context->camera.distance = luaL_checknumber(L, 1);
refresh_camera(context);
return 0;
}
// camera.get_distance() -> d
static int lua_camera_get_distance(lua_State* L) {
lua_pushnumber(L, current_context(L)->camera.distance);
return 1;
}
void editor_lua_register(lua_State* L, ClientContext* context) {
lua_pushlightuserdata(L, context);
lua_setfield(L, LUA_REGISTRYINDEX, EDITOR_LUA_CONTEXT);
static const luaL_Reg camera_funcs[] = {
{"set_position", lua_camera_set_position},
{"get_position", lua_camera_get_position},
{"set_rotation", lua_camera_set_rotation},
{"get_rotation", lua_camera_get_rotation},
{"set_distance", lua_camera_set_distance},
{"get_distance", lua_camera_get_distance},
{NULL, NULL},
};
luaL_newlib(L, camera_funcs);
lua_setglobal(L, "camera");
}

@ -2,5 +2,5 @@
int main() { int main() {
EditorData* data = create_editor_data(); EditorData* data = create_editor_data();
return run_app(data, editor_startup, editor_frame_callback, NULL, editor_key_callback, editor_button_callback, editor_scroll_callback, NULL); return run_app(data, editor_startup, editor_frame_callback, NULL, editor_key_callback, editor_button_callback, editor_scroll_callback, editor_cursor_callback);
} }

@ -31,6 +31,13 @@ void key_callback(GLFWwindow* window, int key, int scancode, int action, int mod
context->ui.active_element, context->ui.active_element,
key, action, mods)) return; key, action, mods)) return;
// Global visibility for scripts: fire-and-forget, doesn't consume or
// otherwise affect app_key's handling below
lua_pushinteger(context->ui.lua, key);
lua_pushinteger(context->ui.lua, action);
lua_pushinteger(context->ui.lua, mods);
event_emit(&context->events, "engine.key", EVENT_SOURCE_ENGINE, 3);
if(context->app_key != NULL) context->app_key(context, key, action, mods); if(context->app_key != NULL) context->app_key(context, key, action, mods);
} }
@ -179,78 +186,28 @@ void cursor_callback(GLFWwindow* window, double xpos, double ypos) {
int app_main(ClientContext* context) { int app_main(ClientContext* context) {
VkResult result; VkResult result;
//
double last_frame_time = 0; double last_frame_time = 0;
while(glfwWindowShouldClose(context->window) == 0) { while(glfwWindowShouldClose(context->window) == 0) {
double frame_time = glfwGetTime(); double frame_time = glfwGetTime();
double delta_time = (frame_time - last_frame_time); double delta_time = (frame_time - last_frame_time);
// Reset callback variables
context->zoom = 0;
glfwPollEvents(); glfwPollEvents();
if(context->app_frame != NULL) context->app_frame(context); if(context->app_frame != NULL) context->app_frame(context, delta_time);
if((context->spin[0] != 0 || context->spin[1] != 0 ||
context->velocity[0] != 0 || context->velocity[1] != 0 || context->velocity[2] != 0 ||
context->zoom != 0 ||
context->render.framebuffer_recreated == true)) {
// Sole drain point: events queued by input callbacks, scripts, or the
// frame callback all dispatch here, before this frame is drawn
event_bus_drain(&context->events, &context->ui, &context->render, delta_time);
// Resize handling here is limited to the UI system's own screen-space
// scale, which every container needs regardless of app. Which cameras/
// containers track the window size (and how) is app policy - see
// editor_frame_callback's own resize check for the main view.
if(context->render.framebuffer_recreated == true) { if(context->render.framebuffer_recreated == true) {
context->render.framebuffer_recreated = false; context->render.framebuffer_recreated = false;
VK_RESULT(update_hex_proj(&context->render, &context->hex));
VK_RESULT(update_ui_context_resolution(&context->ui, &context->render)); VK_RESULT(update_ui_context_resolution(&context->ui, &context->render));
} }
context->rotation[0] += (float)context->spin[0]*delta_time*context->spin_speed; VkResult result = draw_frame(&context->render, &context->ui, &context->hex, context->offscreen_camera, frame_time);
if(context->rotation[0] > 2*M_PI) {
context->rotation[0] -= 2*M_PI;
} else if(context->rotation[0] < 0) {
context->rotation[0] += 2*M_PI;
}
context->rotation[1] += (float)context->spin[1]*delta_time*context->spin_speed;
if(context->rotation[1] > (M_PI/2 - 0.1)) {
context->rotation[1] = (M_PI/2 - 0.1);
} else if(context->rotation[1] < 0) {
context->rotation[1] = 0;
}
float move_x = context->velocity[0];
float move_z = context->velocity[2];
float move_mag = sqrt(move_x*move_x + move_z*move_z);
if(move_mag > 1) {
move_x /= move_mag;
move_z /= move_mag;
}
context->position[0] += - move_z*context->move_speed*cos(context->rotation[0])
- move_x*context->move_speed*sin(context->rotation[0]);
context->position[2] += move_x*context->move_speed*cos(context->rotation[0])
- move_z*context->move_speed*sin(context->rotation[0]);
context->position[1] += context->velocity[1]*context->move_speed;
// context->zoom is a one-frame scroll impulse (reset to 0 each frame,
// above), not a held input like spin/velocity, so it isn't scaled by
// delta_time.
context->distance += context->zoom*context->zoom_speed;
if(context->distance < 1) {
context->distance = 1;
}
VK_RESULT(update_hex_view(
context->position,
context->rotation,
context->distance,
&context->render,
&context->hex));
}
//
VkResult result = draw_frame(&context->render, &context->ui, &context->hex, frame_time);
if(result != VK_SUCCESS) { if(result != VK_SUCCESS) {
fprintf(stderr, "draw_frame error: %s\n", string_VkResult(result)); fprintf(stderr, "draw_frame error: %s\n", string_VkResult(result));
glfwDestroyWindow(context->window); glfwDestroyWindow(context->window);
@ -281,13 +238,6 @@ int run_app(
bool visible = getenv("ROLEPLAY_HEADLESS_TEST") == NULL; bool visible = getenv("ROLEPLAY_HEADLESS_TEST") == NULL;
context->window = init_window(visible); context->window = init_window(visible);
context->rotation[0] = 3*M_PI/2;
context->rotation[1] = M_PI/4;
context->distance = 25;
context->spin_speed = 1.0;
context->zoom_speed = 0.5;
context->move_speed = 0.1;
memset(&context->render, 0, sizeof(RenderContext)); memset(&context->render, 0, sizeof(RenderContext));
memset(&context->ui, 0, sizeof(UIContext)); memset(&context->ui, 0, sizeof(UIContext));
memset(&context->hex, 0, sizeof(HexContext)); memset(&context->hex, 0, sizeof(HexContext));
@ -312,6 +262,9 @@ int run_app(
// TODO: make # of fonts/textures/containers scaling, recreate GPU buffers as necessary // TODO: make # of fonts/textures/containers scaling, recreate GPU buffers as necessary
if(create_ui_context(10, 10, 10, &context->render, &context->ui) != VK_SUCCESS) return -3; if(create_ui_context(10, 10, 10, &context->render, &context->ui) != VK_SUCCESS) return -3;
if(create_hex_context(&context->render, &context->hex) != VK_SUCCESS) return -4; if(create_hex_context(&context->render, &context->hex) != VK_SUCCESS) return -4;
if(create_camera(&context->render, &context->camera) != VK_SUCCESS) return -6;
if(event_bus_init(&context->events, context->ui.lua) != VK_SUCCESS) return -5;
context->ui.events = &context->events;
if(app_startup != NULL) app_startup(context); if(app_startup != NULL) app_startup(context);

@ -0,0 +1,424 @@
#include "events.h"
#include "ui_lua.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define EVENT_LUA_BUS "event_bus"
static EventBus* lua_bus(lua_State* L) {
lua_getfield(L, LUA_REGISTRYINDEX, EVENT_LUA_BUS);
EventBus* bus = lua_touserdata(L, -1);
lua_pop(L, 1);
return bus;
}
static VkResult grow(void** array, uint32_t count, uint32_t* cap, size_t stride) {
if(count < *cap) return VK_SUCCESS;
uint32_t new_cap = (*cap == 0) ? 8 : (*cap)*2;
void* grown = realloc(*array, new_cap*stride);
if(grown == NULL) return VK_ERROR_OUT_OF_HOST_MEMORY;
*array = grown;
*cap = new_cap;
return VK_SUCCESS;
}
// ---------- queue ----------
VkResult event_emit(EventBus* bus, const char* name, uint32_t source, int nargs) {
lua_State* L = bus->L;
VkResult result = grow((void**)&bus->queue, bus->queue_count, &bus->queue_cap, sizeof(QueuedEvent));
if(result != VK_SUCCESS) {
lua_pop(L, nargs);
return result;
}
// Pack the nargs stack values into a table.pack-style {1..n, n=n} table
lua_createtable(L, nargs, 1);
lua_insert(L, -(nargs + 1));
for(int i = nargs; i >= 1; i--) {
lua_rawseti(L, -(i + 1), i);
}
lua_pushinteger(L, nargs);
lua_setfield(L, -2, "n");
QueuedEvent* ev = &bus->queue[bus->queue_count];
ev->args_ref = luaL_ref(L, LUA_REGISTRYINDEX);
ev->source = source;
ev->name = strdup(name);
if(ev->name == NULL) {
luaL_unref(L, LUA_REGISTRYINDEX, ev->args_ref);
return VK_ERROR_OUT_OF_HOST_MEMORY;
}
bus->queue_count += 1;
return VK_SUCCESS;
}
// ---------- subscriptions ----------
static VkResult add_subscription(
EventBus* bus,
const char* name,
int script_env,
int lua_ref,
EventHandler handler,
void* userdata) {
VkResult result = grow((void**)&bus->subs, bus->sub_count, &bus->sub_cap, sizeof(EventSubscription));
if(result != VK_SUCCESS) return result;
EventSubscription* sub = &bus->subs[bus->sub_count];
sub->event = strdup(name);
if(sub->event == NULL) return VK_ERROR_OUT_OF_HOST_MEMORY;
sub->script_env = script_env;
sub->lua_ref = lua_ref;
sub->handler = handler;
sub->userdata = userdata;
sub->dead = 0;
bus->sub_count += 1;
return VK_SUCCESS;
}
VkResult event_subscribe(EventBus* bus, const char* name, EventHandler handler, void* userdata) {
return add_subscription(bus, name, LUA_NOREF, LUA_NOREF, handler, userdata);
}
static void compact_subscriptions(EventBus* bus) {
uint32_t out = 0;
for(uint32_t i = 0; i < bus->sub_count; i++) {
if(bus->subs[i].dead) continue;
bus->subs[out] = bus->subs[i];
out += 1;
}
bus->sub_count = out;
}
// ---------- drain ----------
static void dispatch_to_lua(
EventBus* bus,
QueuedEvent* ev,
EventSubscription* sub,
UIContext* ui,
RenderContext* gpu) {
lua_State* L = bus->L;
lua_rawgeti(L, LUA_REGISTRYINDEX, ev->args_ref);
int args_index = lua_gettop(L);
lua_getfield(L, args_index, "n");
int n = (int)lua_tointeger(L, -1);
lua_pop(L, 1);
if(!lua_checkstack(L, n + 4)) {
fprintf(stderr, "event %s: args overflow lua stack\n", ev->name);
lua_pop(L, 1);
return;
}
// No specific overlay is "current" for a bus-fired handler; ui.rect/text
// require an explicit ui.create_overlay call first if one runs from here
ui_lua_set_current_script(L, sub->script_env);
ui_lua_set_current(L, NULL, ui, gpu);
lua_rawgeti(L, LUA_REGISTRYINDEX, sub->lua_ref);
lua_pushinteger(L, ev->source);
for(int i = 1; i <= n; i++) {
lua_rawgeti(L, args_index, i);
}
lua_remove(L, args_index);
if(lua_pcall(L, 1 + n, 0, 0) != LUA_OK) {
fprintf(stderr, "event %s: %s\n", ev->name, lua_tostring(L, -1));
lua_pop(L, 1);
}
}
// sub_count is re-read every iteration: handlers may subscribe during the
// dispatch, and late subscribers still receive the in-flight event
static void dispatch_event(EventBus* bus, QueuedEvent* ev, UIContext* ui, RenderContext* gpu) {
lua_State* L = bus->L;
for(uint32_t i = 0; i < bus->sub_count; i++) {
if(bus->subs[i].dead || strcmp(bus->subs[i].event, ev->name) != 0) continue;
if(bus->subs[i].handler != NULL) {
lua_rawgeti(L, LUA_REGISTRYINDEX, ev->args_ref);
bus->subs[i].handler(bus->subs[i].userdata, ev->name, ev->source, L, lua_gettop(L));
lua_pop(L, 1);
} else {
dispatch_to_lua(bus, ev, &bus->subs[i], ui, gpu);
}
}
}
void event_bus_drain(EventBus* bus, UIContext* ui, RenderContext* gpu, double delta_time) {
lua_State* L = bus->L;
bus->draining = true;
uint32_t head = 0;
uint32_t processed = 0;
while(head < bus->queue_count && processed < EVENT_DRAIN_MAX) {
// Copy: handlers can emit, which may realloc the queue
QueuedEvent ev = bus->queue[head];
head += 1;
processed += 1;
dispatch_event(bus, &ev, ui, gpu);
luaL_unref(L, LUA_REGISTRYINDEX, ev.args_ref);
free(ev.name);
}
if(head < bus->queue_count) {
fprintf(stderr,
"event_bus_drain: cap of %d hit, dropping %d events (handler cycle?)\n",
EVENT_DRAIN_MAX,
bus->queue_count - head);
for(uint32_t i = head; i < bus->queue_count; i++) {
fprintf(stderr, " dropped: %s (source %u)\n", bus->queue[i].name, bus->queue[i].source);
luaL_unref(L, LUA_REGISTRYINDEX, bus->queue[i].args_ref);
free(bus->queue[i].name);
}
}
bus->queue_count = 0;
// engine.frame runs after the queue is empty so its handlers see the frame
// fully settled; anything they emit lands in next frame's drain (the queue
// was just reset). Skipped entirely when nothing subscribes, to avoid
// per-frame table garbage.
bool frame_wanted = false;
for(uint32_t i = 0; i < bus->sub_count; i++) {
if(!bus->subs[i].dead && strcmp(bus->subs[i].event, EVENT_FRAME) == 0) {
frame_wanted = true;
break;
}
}
if(frame_wanted) {
lua_createtable(L, 1, 1);
lua_pushnumber(L, delta_time);
lua_rawseti(L, -2, 1);
lua_pushinteger(L, 1);
lua_setfield(L, -2, "n");
QueuedEvent frame_ev = {
.name = (char*)EVENT_FRAME,
.source = EVENT_SOURCE_ENGINE,
.args_ref = luaL_ref(L, LUA_REGISTRYINDEX),
};
dispatch_event(bus, &frame_ev, ui, gpu);
luaL_unref(L, LUA_REGISTRYINDEX, frame_ev.args_ref);
}
bus->draining = false;
compact_subscriptions(bus);
}
// ---------- property registry ----------
Property* event_property(EventBus* bus, const char* name) {
for(uint32_t i = 0; i < bus->property_count; i++) {
if(strcmp(bus->properties[i].name, name) == 0) return &bus->properties[i];
}
return NULL;
}
VkResult event_property_register(EventBus* bus, const char* name, PropertyType type) {
if(event_property(bus, name) != NULL) return VK_ERROR_VALIDATION_FAILED_EXT;
VkResult result = grow((void**)&bus->properties, bus->property_count, &bus->property_cap, sizeof(Property));
if(result != VK_SUCCESS) return result;
Property* p = &bus->properties[bus->property_count];
memset(p, 0, sizeof(Property));
p->name = strdup(name);
if(p->name == NULL) return VK_ERROR_OUT_OF_HOST_MEMORY;
p->type = type;
bus->property_count += 1;
return VK_SUCCESS;
}
// The changed value is already on top of the Lua stack
static VkResult emit_changed(EventBus* bus, Property* p, uint32_t source) {
char name[256];
int written = snprintf(name, sizeof(name), EVENT_CHANGED_PREFIX "%s", p->name);
if(written < 0 || (size_t)written >= sizeof(name)) {
lua_pop(bus->L, 1);
return VK_ERROR_VALIDATION_FAILED_EXT;
}
return event_emit(bus, name, source, 1);
}
VkResult event_property_set_number(EventBus* bus, const char* name, double value, uint32_t source) {
Property* p = event_property(bus, name);
if(p == NULL || p->type != PROPERTY_NUMBER) return VK_ERROR_VALIDATION_FAILED_EXT;
if(p->value.number == value) return VK_SUCCESS;
p->value.number = value;
lua_pushnumber(bus->L, value);
return emit_changed(bus, p, source);
}
VkResult event_property_set_string(EventBus* bus, const char* name, const char* value, uint32_t source) {
Property* p = event_property(bus, name);
if(p == NULL || p->type != PROPERTY_STRING || value == NULL) return VK_ERROR_VALIDATION_FAILED_EXT;
if(p->value.string != NULL && strcmp(p->value.string, value) == 0) return VK_SUCCESS;
char* copy = strdup(value);
if(copy == NULL) return VK_ERROR_OUT_OF_HOST_MEMORY;
free(p->value.string);
p->value.string = copy;
lua_pushstring(bus->L, value);
return emit_changed(bus, p, source);
}
VkResult event_property_set_bool(EventBus* bus, const char* name, bool value, uint32_t source) {
Property* p = event_property(bus, name);
if(p == NULL || p->type != PROPERTY_BOOL) return VK_ERROR_VALIDATION_FAILED_EXT;
if(p->value.boolean == value) return VK_SUCCESS;
p->value.boolean = value;
lua_pushboolean(bus->L, value);
return emit_changed(bus, p, source);
}
// ---------- C handler arg accessors ----------
int event_arg_count(lua_State* L, int args_index) {
lua_getfield(L, args_index, "n");
int n = (int)lua_tointeger(L, -1);
lua_pop(L, 1);
return n;
}
double event_arg_number(lua_State* L, int args_index, int i) {
lua_rawgeti(L, args_index, i);
double v = lua_tonumber(L, -1);
lua_pop(L, 1);
return v;
}
bool event_arg_bool(lua_State* L, int args_index, int i) {
lua_rawgeti(L, args_index, i);
bool v = lua_toboolean(L, -1);
lua_pop(L, 1);
return v;
}
const char* event_arg_string(lua_State* L, int args_index, int i) {
lua_rawgeti(L, args_index, i);
const char* v = lua_tostring(L, -1);
lua_pop(L, 1);
return v;
}
// ---------- lua bindings ----------
// app.emit(name, ...) — queues a custom event from the calling container
static int lua_app_emit(lua_State* L) {
EventBus* bus = lua_bus(L);
const char* name = luaL_checkstring(L, 1);
if(strncmp(name, EVENT_ENGINE_PREFIX, strlen(EVENT_ENGINE_PREFIX)) == 0) {
return luaL_error(L, "emit: the %s* namespace is reserved for the engine", EVENT_ENGINE_PREFIX);
}
Container* c = ui_lua_current_container(L);
uint32_t source = (c != NULL) ? c->id : EVENT_SOURCE_ENGINE;
// Everything above the name is payload; emit consumes it off the stack
int nargs = lua_gettop(L) - 1;
if(event_emit(bus, name, source, nargs) != VK_SUCCESS) {
return luaL_error(L, "emit: failed to queue %s", name);
}
return 0;
}
// app.subscribe(name, fn) — fn is called as fn(source, ...) during drains
static int lua_app_subscribe(lua_State* L) {
EventBus* bus = lua_bus(L);
const char* name = luaL_checkstring(L, 1);
luaL_checktype(L, 2, LUA_TFUNCTION);
int script_env = ui_lua_current_script(L);
if(script_env == LUA_NOREF) {
return luaL_error(L, "subscribe: no script context");
}
lua_pushvalue(L, 2);
int ref = luaL_ref(L, LUA_REGISTRYINDEX);
if(add_subscription(bus, name, script_env, ref, NULL, NULL) != VK_SUCCESS) {
luaL_unref(L, LUA_REGISTRYINDEX, ref);
return luaL_error(L, "subscribe: failed to subscribe to %s", name);
}
return 0;
}
// app.get(name) -> value or nil if the property is unregistered/unset
static int lua_app_get(lua_State* L) {
EventBus* bus = lua_bus(L);
Property* p = event_property(bus, luaL_checkstring(L, 1));
if(p == NULL) {
lua_pushnil(L);
return 1;
}
switch(p->type) {
case PROPERTY_NUMBER: lua_pushnumber(L, p->value.number); break;
case PROPERTY_BOOL: lua_pushboolean(L, p->value.boolean); break;
case PROPERTY_STRING:
if(p->value.string == NULL) lua_pushnil(L);
else lua_pushstring(L, p->value.string);
break;
}
return 1;
}
// app.set(name, value) — writes a registered property; a real change emits
// engine.changed.<name> with the calling container as the source
static int lua_app_set(lua_State* L) {
EventBus* bus = lua_bus(L);
const char* name = luaL_checkstring(L, 1);
Property* p = event_property(bus, name);
if(p == NULL) {
return luaL_error(L, "set: unknown property %s", name);
}
Container* c = ui_lua_current_container(L);
uint32_t source = (c != NULL) ? c->id : EVENT_SOURCE_ENGINE;
VkResult result = VK_SUCCESS;
switch(p->type) {
case PROPERTY_NUMBER:
result = event_property_set_number(bus, name, luaL_checknumber(L, 2), source);
break;
case PROPERTY_STRING:
result = event_property_set_string(bus, name, luaL_checkstring(L, 2), source);
break;
case PROPERTY_BOOL:
luaL_checktype(L, 2, LUA_TBOOLEAN);
result = event_property_set_bool(bus, name, lua_toboolean(L, 2), source);
break;
}
if(result != VK_SUCCESS) {
return luaL_error(L, "set: failed to set %s", name);
}
return 0;
}
VkResult event_bus_init(EventBus* bus, lua_State* L) {
memset(bus, 0, sizeof(EventBus));
bus->L = L;
lua_pushlightuserdata(L, bus);
lua_setfield(L, LUA_REGISTRYINDEX, EVENT_LUA_BUS);
static const luaL_Reg app_funcs[] = {
{"emit", lua_app_emit},
{"subscribe", lua_app_subscribe},
{"get", lua_app_get},
{"set", lua_app_set},
{NULL, NULL},
};
luaL_newlib(L, app_funcs);
lua_setglobal(L, "app");
return VK_SUCCESS;
}

@ -738,13 +738,6 @@ VkResult create_hex_context(
VK_RESULT(create_point_pipeline(gpu, &context->point_pipeline)); VK_RESULT(create_point_pipeline(gpu, &context->point_pipeline));
memset(&context->data, 0, sizeof(GPUHexContext)); memset(&context->data, 0, sizeof(GPUHexContext));
glm_perspective(
PERSPECTIVE_FOVY,
(float)gpu->swapchain_extent.width/(float)gpu->swapchain_extent.height,
PERSPECTIVE_NEARZ,
PERSPECTIVE_FARZ,
context->data.proj);
glm_mat4_identity(context->data.view);
for(uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) { for(uint32_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
VK_RESULT(create_storage_buffer( VK_RESULT(create_storage_buffer(
gpu->allocator, gpu->allocator,
@ -1123,41 +1116,10 @@ bool ray_world_intersect(
return intersect; return intersect;
} }
VkResult update_hex_proj(RenderContext* gpu, HexContext* hex) { void update_hex_picking_inverse(Camera* camera, HexContext* hex) {
glm_perspective(
PERSPECTIVE_FOVY,
(float)gpu->swapchain_extent.width/(float)gpu->swapchain_extent.height,
PERSPECTIVE_NEARZ,
PERSPECTIVE_FARZ,
hex->data.proj);
return add_transfers(
&hex->data.proj,
hex->context,
offsetof(GPUHexContext, proj),
sizeof(mat4),
gpu);
}
vec3 up = {0, 1, 0};
VkResult update_hex_view(
vec3 position,
vec2 rotation,
double distance,
RenderContext* gpu,
HexContext* hex) {
vec3 camera = {};
camera[0] = position[0] + distance*cos(rotation[1])*cos(rotation[0]);
camera[1] = position[1] + distance*sin(rotation[1]);
camera[2] = position[2] + distance*cos(rotation[1])*sin(rotation[0]);
glm_lookat(camera, position, up, hex->data.view);
mat4 regular; mat4 regular;
glm_mat4_mul(hex->data.proj, hex->data.view, regular); glm_mat4_mul(camera->proj, camera->view, regular);
glm_mat4_inv(regular, hex->inverse); glm_mat4_inv(regular, hex->inverse);
return add_transfers(&hex->data, hex->context, 0, 2*sizeof(mat4), gpu);
} }
void cursor_to_world_ray(RenderContext* gpu, mat4 inverse, double cursor[2], vec4 start, vec4 end) { void cursor_to_world_ray(RenderContext* gpu, mat4 inverse, double cursor[2], vec4 start, vec4 end) {

@ -15,6 +15,7 @@
VkResult create_ui_pipeline( VkResult create_ui_pipeline(
VkDevice device, VkDevice device,
VkFormat color_format, VkFormat color_format,
VkFormat depth_format,
VkDescriptorSetLayout samplers_layout, VkDescriptorSetLayout samplers_layout,
VkDescriptorSetLayout textures_layout, VkDescriptorSetLayout textures_layout,
GraphicsPipeline* pipeline, GraphicsPipeline* pipeline,
@ -206,6 +207,18 @@ VkResult create_ui_pipeline(
.sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO, .sType = VK_STRUCTURE_TYPE_PIPELINE_RENDERING_CREATE_INFO,
.colorAttachmentCount = 1, .colorAttachmentCount = 1,
.pColorAttachmentFormats = &color_format, .pColorAttachmentFormats = &color_format,
.depthAttachmentFormat = depth_format,
};
// UI draws inside the same rendering scope as screen-region camera
// passes (see draw_frame), which binds a depth attachment throughout -
// every pipeline used in that scope must declare a matching
// depthAttachmentFormat above. UI itself stays purely painter's-order via
// container_order: it never tests or writes depth.
VkPipelineDepthStencilStateCreateInfo depth_info = {
.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO,
.depthTestEnable = VK_FALSE,
.depthWriteEnable = VK_FALSE,
}; };
VkGraphicsPipelineCreateInfo draw_pipeline_info = { VkGraphicsPipelineCreateInfo draw_pipeline_info = {
@ -219,6 +232,7 @@ VkResult create_ui_pipeline(
.pColorBlendState = &color_blend_info, .pColorBlendState = &color_blend_info,
.pDynamicState = &dynamic_info, .pDynamicState = &dynamic_info,
.pMultisampleState = &multisample_info, .pMultisampleState = &multisample_info,
.pDepthStencilState = &depth_info,
.pNext = &rendering_info, .pNext = &rendering_info,
.layout = pipeline->layout, .layout = pipeline->layout,
.renderPass = VK_NULL_HANDLE, .renderPass = VK_NULL_HANDLE,
@ -672,17 +686,17 @@ VkResult unload_container(
} }
} }
// Drop focus without dispatching on_deselect: the script is being unloaded // Drop focus without dispatching on_deselect: the container is being unloaded
if(context->active_container == container) { if(context->active_container == container) {
context->active_container = NULL; context->active_container = NULL;
context->active_element = 0; context->active_element = 0;
} }
if(container->script_env != 0) { // script_env is not unref'd here: it belongs to the owning script, not
luaL_unref(context->lua, LUA_REGISTRYINDEX, container->script_env); // this container, and other containers (or none) may still reference it.
} // Bus subscriptions are keyed the same way and outlive any one container.
if(container->script_path != NULL) { if(container->overlay_ref != LUA_NOREF) {
free(container->script_path); luaL_unref(context->lua, LUA_REGISTRYINDEX, container->overlay_ref);
} }
memset(container, 0, sizeof(Container)); memset(container, 0, sizeof(Container));
@ -782,24 +796,21 @@ VkResult load_container(
} }
c->id = input->id; c->id = input->id;
c->script_env = 0;
c->overlay_ref = LUA_NOREF;
context->container_order[context->container_order_count] = index; context->container_order[context->container_order_count] = index;
context->container_order_count += 1; context->container_order_count += 1;
VK_RESULT(rebuild_indices(c, gpu)); VK_RESULT(rebuild_indices(c, gpu));
if(input->script_path != NULL) {
c->script_path = strdup(input->script_path);
VK_RESULT(ui_lua_load_container(
context->lua,
c,
context,
gpu,
input->script_path));
}
return VK_SUCCESS; return VK_SUCCESS;
} }
uint32_t ui_alloc_container_id(UIContext* context) {
context->next_container_id += 1;
return context->next_container_id;
}
VkResult load_texture( VkResult load_texture(
const char* png_path, const char* png_path,
RenderContext* gpu, RenderContext* gpu,
@ -1008,6 +1019,111 @@ VkResult load_texture(
return VK_SUCCESS; return VK_SUCCESS;
} }
VkResult create_render_target_texture(
RenderContext* gpu,
UIContext* context,
uint32_t width,
uint32_t height,
uint32_t* index) {
*index = 0xFFFFFFFF;
for(uint32_t i = 0; i < context->max_textures; i++) {
if(context->texture_slots[i].path == NULL) {
static const char placeholder[] = "<render-target>";
context->texture_slots[i].path = malloc(sizeof(placeholder));
memcpy(context->texture_slots[i].path, placeholder, sizeof(placeholder));
*index = i;
break;
}
}
if(*index == 0xFFFFFFFF) {
return VK_ERROR_OUT_OF_DEVICE_MEMORY;
}
VkResult result;
VkImageCreateInfo image_info = {
.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO,
.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
.extent = {
.width = width,
.height = height,
.depth = 1,
},
.mipLevels = 1,
.arrayLayers = 1,
.format = gpu->swapchain_format.format,
.tiling = VK_IMAGE_TILING_OPTIMAL,
.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED,
.samples = VK_SAMPLE_COUNT_1_BIT,
.imageType = VK_IMAGE_TYPE_2D,
};
VmaAllocationCreateInfo memory_info = {
.usage = VMA_MEMORY_USAGE_GPU_ONLY,
};
VK_RESULT(vmaCreateImage(gpu->allocator, &image_info, &memory_info, &context->texture_slots[*index].image, &context->texture_slots[*index].image_memory, NULL));
VkImageViewCreateInfo view_info = {
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.image = context->texture_slots[*index].image,
.viewType = VK_IMAGE_VIEW_TYPE_2D,
.format = gpu->swapchain_format.format,
.subresourceRange = {
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.layerCount = 1,
.levelCount = 1,
},
};
VK_RESULT(vkCreateImageView(gpu->device, &view_info, NULL, &context->texture_slots[*index].view));
VkSamplerCreateInfo sampler_info = {
.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO,
.magFilter = VK_FILTER_LINEAR,
.minFilter = VK_FILTER_LINEAR,
.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE,
};
VK_RESULT(vkCreateSampler(gpu->device, &sampler_info, NULL, &context->texture_slots[*index].sampler));
VkDescriptorImageInfo desc_sampler_info = {
.sampler = context->texture_slots[*index].sampler,
};
VkDescriptorImageInfo desc_image_info = {
.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
.imageView = context->texture_slots[*index].view,
};
VkWriteDescriptorSet desc_writes[] = {
{
.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
.dstSet = context->textures,
.dstBinding = 0,
.dstArrayElement = *index,
.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE,
.descriptorCount = 1,
.pImageInfo = &desc_image_info,
},
{
.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
.dstSet = context->samplers,
.dstBinding = 0,
.dstArrayElement = *index,
.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLER,
.descriptorCount = 1,
.pImageInfo = &desc_sampler_info,
},
};
vkUpdateDescriptorSets(gpu->device, sizeof(desc_writes)/sizeof(VkWriteDescriptorSet), desc_writes, 0, NULL);
return VK_SUCCESS;
}
VkResult load_font( VkResult load_font(
uint32_t index, uint32_t index,
const char* ttf_file, const char* ttf_file,
@ -1528,6 +1644,7 @@ VkResult create_ui_context(
VK_RESULT(create_ui_pipeline( VK_RESULT(create_ui_pipeline(
gpu->device, gpu->device,
gpu->swapchain_format.format, gpu->swapchain_format.format,
gpu->depth_format,
context->samplers_layout, context->samplers_layout,
context->textures_layout, context->textures_layout,
&context->pipeline, &context->pipeline,
@ -1545,6 +1662,7 @@ VkResult create_ui_context(
memset(context->containers, 0, max_containers*sizeof(Container)); memset(context->containers, 0, max_containers*sizeof(Container));
context->container_order = malloc(max_containers*sizeof(uint32_t)); context->container_order = malloc(max_containers*sizeof(uint32_t));
context->container_order_count = 0; context->container_order_count = 0;
context->next_container_id = 0;
context->lua = luaL_newstate(); context->lua = luaL_newstate();
if(context->lua == NULL) { if(context->lua == NULL) {
@ -1759,3 +1877,17 @@ void anchor_offset(RenderContext* gpu, Container* container, vec2 offset) {
break; break;
} }
} }
void container_set_camera(Container* container, Camera* camera) {
container->camera = camera;
}
void container_screen_rect(RenderContext* gpu, Container* container, VkRect2D* out) {
vec2 offset = {container->data.offset[0], container->data.offset[1]};
anchor_offset(gpu, container, offset);
out->offset.x = (int32_t)(offset[0]*gpu->window_scale[0]);
out->offset.y = (int32_t)(offset[1]*gpu->window_scale[1]);
out->extent.width = (uint32_t)(container->data.size[0]*gpu->window_scale[0]);
out->extent.height = (uint32_t)(container->data.size[1]*gpu->window_scale[1]);
}

@ -10,8 +10,17 @@
#define UI_LUA_CONTAINER "ui_container" #define UI_LUA_CONTAINER "ui_container"
#define UI_LUA_CONTEXT "ui_context" #define UI_LUA_CONTEXT "ui_context"
#define UI_LUA_GPU "ui_gpu" #define UI_LUA_GPU "ui_gpu"
#define UI_LUA_SCRIPT "ui_script"
#define UI_LUA_ELEMENT_META "ui_element" #define UI_LUA_ELEMENT_META "ui_element"
#define UI_LUA_OVERLAY_META "ui_overlay"
// Opaque overlay handle passed to scripts. Containers are never recycled
// under a reused id (UIContext.next_container_id only counts up), so unlike
// element handles, a plain id lookup is enough to detect a destroyed overlay.
typedef struct UIOverlayHandleStruct {
uint32_t container_id;
} UIOverlayHandle;
// Opaque element handle passed to scripts. container_id + generation let the // Opaque element handle passed to scripts. container_id + generation let the
// binding detect stale handles (container unloaded / slot recycled) and raise // binding detect stale handles (container unloaded / slot recycled) and raise
@ -39,6 +48,18 @@ static void set_registry_pointers(lua_State* L, Container* c, UIContext* ui, Ren
lua_setfield(L, LUA_REGISTRYINDEX, UI_LUA_GPU); lua_setfield(L, LUA_REGISTRYINDEX, UI_LUA_GPU);
} }
int ui_lua_current_script(lua_State* L) {
lua_getfield(L, LUA_REGISTRYINDEX, UI_LUA_SCRIPT);
int ref = lua_isnil(L, -1) ? LUA_NOREF : (int)lua_tointeger(L, -1);
lua_pop(L, 1);
return ref;
}
void ui_lua_set_current_script(lua_State* L, int script_env) {
lua_pushinteger(L, script_env);
lua_setfield(L, LUA_REGISTRYINDEX, UI_LUA_SCRIPT);
}
// ---------- handle plumbing ---------- // ---------- handle plumbing ----------
static UIElementHandle* check_handle(lua_State* L, int idx) { static UIElementHandle* check_handle(lua_State* L, int idx) {
@ -95,6 +116,42 @@ static void push_element_handle(lua_State* L, Container* c, uint32_t element) {
} }
} }
// ---------- overlay handle plumbing ----------
static UIOverlayHandle* check_overlay(lua_State* L, int idx) {
return luaL_checkudata(L, idx, UI_LUA_OVERLAY_META);
}
static Container* resolve_overlay(lua_State* L, UIOverlayHandle* h) {
UIContext* ui = registry_pointer(L, UI_LUA_CONTEXT);
Container* c = context_container(h->container_id, ui);
if(c == NULL) {
luaL_error(L, "overlay %d is destroyed", h->container_id);
}
return c;
}
// Creates the container's one overlay handle and caches its ref so every
// later dispatch/lookup hands back the same object (scripts compare
// overlays by identity, e.g. `overlay == picker_overlay`)
static void push_new_overlay_handle(lua_State* L, Container* c) {
UIOverlayHandle* h = lua_newuserdata(L, sizeof(UIOverlayHandle));
h->container_id = c->id;
luaL_setmetatable(L, UI_LUA_OVERLAY_META);
lua_pushvalue(L, -1);
c->overlay_ref = luaL_ref(L, LUA_REGISTRYINDEX);
}
// Pushes the container's cached overlay handle (created by ui.create_overlay)
static void push_overlay_handle(lua_State* L, Container* c) {
if(c->overlay_ref == LUA_NOREF) {
lua_pushnil(L);
} else {
lua_rawgeti(L, LUA_REGISTRYINDEX, c->overlay_ref);
}
}
// ---------- declaration table parsing ---------- // ---------- declaration table parsing ----------
static float opt_number_field(lua_State* L, int table, const char* key, float def) { static float opt_number_field(lua_State* L, int table, const char* key, float def) {
@ -171,6 +228,9 @@ static void opt_corner_colors(lua_State* L, int table, vec4 corners[4]) {
static int lua_ui_rect(lua_State* L) { static int lua_ui_rect(lua_State* L) {
Container* container = registry_pointer(L, UI_LUA_CONTAINER); Container* container = registry_pointer(L, UI_LUA_CONTAINER);
RenderContext* gpu = registry_pointer(L, UI_LUA_GPU); RenderContext* gpu = registry_pointer(L, UI_LUA_GPU);
if(container == NULL) {
return luaL_error(L, "ui.rect: no active overlay (call ui.create_overlay first)");
}
luaL_checktype(L, 1, LUA_TTABLE); luaL_checktype(L, 1, LUA_TTABLE);
GPUDrawable drawable; GPUDrawable drawable;
@ -197,6 +257,9 @@ static int lua_ui_text(lua_State* L) {
Container* container = registry_pointer(L, UI_LUA_CONTAINER); Container* container = registry_pointer(L, UI_LUA_CONTAINER);
UIContext* ui = registry_pointer(L, UI_LUA_CONTEXT); UIContext* ui = registry_pointer(L, UI_LUA_CONTEXT);
RenderContext* gpu = registry_pointer(L, UI_LUA_GPU); RenderContext* gpu = registry_pointer(L, UI_LUA_GPU);
if(container == NULL) {
return luaL_error(L, "ui.text: no active overlay (call ui.create_overlay first)");
}
luaL_checktype(L, 1, LUA_TTABLE); luaL_checktype(L, 1, LUA_TTABLE);
GPUString string; GPUString string;
@ -385,6 +448,28 @@ static int lua_element_remove(lua_State* L) {
return 0; return 0;
} }
// ---------- overlay handle methods ----------
// overlay:to_front()
static int lua_overlay_to_front(lua_State* L) {
UIOverlayHandle* h = check_overlay(L, 1);
UIContext* ui = registry_pointer(L, UI_LUA_CONTEXT);
resolve_overlay(L, h);
ui_container_to_front(h->container_id, ui);
return 0;
}
// overlay:destroy()
static int lua_overlay_destroy(lua_State* L) {
UIOverlayHandle* h = check_overlay(L, 1);
resolve_overlay(L, h);
UIContext* ui = registry_pointer(L, UI_LUA_CONTEXT);
RenderContext* gpu = registry_pointer(L, UI_LUA_GPU);
unload_container(h->container_id, gpu, ui);
return 0;
}
// ---------- ui table functions ---------- // ---------- ui table functions ----------
// ui.blur() // ui.blur()
@ -396,13 +481,33 @@ static int lua_ui_blur(lua_State* L) {
return 0; return 0;
} }
// ui.raise() — bring the calling script's container to the front // ui.create_overlay{anchor=, offset=, size=} -> overlay handle
static int lua_ui_raise(lua_State* L) { //
Container* container = registry_pointer(L, UI_LUA_CONTAINER); // Creates a container and attributes it to whichever script is currently
// running (see ui_lua_current_script). Becomes the active overlay for any
// ui.rect/ui.text calls that follow, in this call or later ones from the
// same script, until another ui.create_overlay changes it.
static int lua_ui_create_overlay(lua_State* L) {
UIContext* ui = registry_pointer(L, UI_LUA_CONTEXT); UIContext* ui = registry_pointer(L, UI_LUA_CONTEXT);
RenderContext* gpu = registry_pointer(L, UI_LUA_GPU);
luaL_checktype(L, 1, LUA_TTABLE);
ui_container_to_front(container->id, ui); ContainerInput input = {0};
return 0; input.id = ui_alloc_container_id(ui);
input.anchor = opt_integer_field(L, 1, "anchor", ANCHOR_TOP_LEFT);
opt_vec2_field(L, 1, "offset", input.offset);
opt_vec2_field(L, 1, "size", input.size);
if(load_container(&input, gpu, ui) != VK_SUCCESS) {
return luaL_error(L, "ui.create_overlay: failed to create overlay");
}
Container* c = context_container(input.id, ui);
c->script_env = ui_lua_current_script(L);
push_new_overlay_handle(L, c);
set_registry_pointers(L, c, ui, gpu);
return 1;
} }
// ui.hsv_to_rgb(h, s, v) -> r, g, b // ui.hsv_to_rgb(h, s, v) -> r, g, b
@ -440,7 +545,7 @@ void ui_lua_register(lua_State* L) {
{"rect", lua_ui_rect}, {"rect", lua_ui_rect},
{"text", lua_ui_text}, {"text", lua_ui_text},
{"blur", lua_ui_blur}, {"blur", lua_ui_blur},
{"raise", lua_ui_raise}, {"create_overlay", lua_ui_create_overlay},
{"hsv_to_rgb", lua_ui_hsv_to_rgb}, {"hsv_to_rgb", lua_ui_hsv_to_rgb},
{"rgb_to_hsv", lua_ui_rgb_to_hsv}, {"rgb_to_hsv", lua_ui_rgb_to_hsv},
{NULL, NULL}, {NULL, NULL},
@ -464,6 +569,22 @@ void ui_lua_register(lua_State* L) {
lua_setfield(L, -2, "__index"); lua_setfield(L, -2, "__index");
lua_pop(L, 1); lua_pop(L, 1);
static const luaL_Reg overlay_methods[] = {
{"to_front", lua_overlay_to_front},
{"destroy", lua_overlay_destroy},
{NULL, NULL},
};
luaL_newmetatable(L, UI_LUA_OVERLAY_META);
luaL_newlib(L, overlay_methods);
lua_setfield(L, -2, "__index");
lua_pop(L, 1);
lua_pushinteger(L, ANCHOR_TOP_LEFT); lua_setglobal(L, "ANCHOR_TOP_LEFT");
lua_pushinteger(L, ANCHOR_TOP_RIGHT); lua_setglobal(L, "ANCHOR_TOP_RIGHT");
lua_pushinteger(L, ANCHOR_BOTTOM_LEFT); lua_setglobal(L, "ANCHOR_BOTTOM_LEFT");
lua_pushinteger(L, ANCHOR_BOTTOM_RIGHT); lua_setglobal(L, "ANCHOR_BOTTOM_RIGHT");
lua_pushinteger(L, ANCHOR_CENTER); lua_setglobal(L, "ANCHOR_CENTER");
lua_pushinteger(L, GLFW_PRESS); lua_setglobal(L, "PRESS"); lua_pushinteger(L, GLFW_PRESS); lua_setglobal(L, "PRESS");
lua_pushinteger(L, GLFW_RELEASE); lua_setglobal(L, "RELEASE"); lua_pushinteger(L, GLFW_RELEASE); lua_setglobal(L, "RELEASE");
lua_pushinteger(L, GLFW_MOUSE_BUTTON_LEFT); lua_setglobal(L, "MOUSE_LEFT"); lua_pushinteger(L, GLFW_MOUSE_BUTTON_LEFT); lua_setglobal(L, "MOUSE_LEFT");
@ -472,6 +593,10 @@ void ui_lua_register(lua_State* L) {
lua_pushinteger(L, GLFW_KEY_ESCAPE); lua_setglobal(L, "KEY_ESCAPE"); lua_pushinteger(L, GLFW_KEY_ESCAPE); lua_setglobal(L, "KEY_ESCAPE");
lua_pushinteger(L, GLFW_KEY_ENTER); lua_setglobal(L, "KEY_ENTER"); lua_pushinteger(L, GLFW_KEY_ENTER); lua_setglobal(L, "KEY_ENTER");
lua_pushinteger(L, GLFW_KEY_BACKSPACE); lua_setglobal(L, "KEY_BACKSPACE"); lua_pushinteger(L, GLFW_KEY_BACKSPACE); lua_setglobal(L, "KEY_BACKSPACE");
lua_pushinteger(L, GLFW_KEY_V); lua_setglobal(L, "KEY_V");
lua_pushinteger(L, GLFW_KEY_N); lua_setglobal(L, "KEY_N");
lua_pushinteger(L, GLFW_KEY_H); lua_setglobal(L, "KEY_H");
lua_pushinteger(L, GLFW_KEY_R); lua_setglobal(L, "KEY_R");
lua_pushinteger(L, DRAWABLE_TYPE_RECT); lua_setglobal(L, "RECT"); lua_pushinteger(L, DRAWABLE_TYPE_RECT); lua_setglobal(L, "RECT");
lua_pushinteger(L, DRAWABLE_TYPE_RECT_HSV); lua_setglobal(L, "RECT_HSV"); lua_pushinteger(L, DRAWABLE_TYPE_RECT_HSV); lua_setglobal(L, "RECT_HSV");
@ -481,15 +606,14 @@ void ui_lua_register(lua_State* L) {
lua_pushinteger(L, UI_EVENT_CURSOR); lua_setglobal(L, "EVENT_CURSOR"); lua_pushinteger(L, UI_EVENT_CURSOR); lua_setglobal(L, "EVENT_CURSOR");
} }
VkResult ui_lua_load_container( VkResult ui_lua_run_script(
lua_State* L, lua_State* L,
Container* c,
UIContext* ui, UIContext* ui,
RenderContext* gpu, RenderContext* gpu,
const char* path) { const char* path) {
if(luaL_loadfile(L, path) != LUA_OK) { if(luaL_loadfile(L, path) != LUA_OK) {
fprintf(stderr, "ui_lua_load_container: %s\n", lua_tostring(L, -1)); fprintf(stderr, "ui_lua_run_script: %s\n", lua_tostring(L, -1));
lua_pop(L, 1); lua_pop(L, 1);
return VK_ERROR_UNKNOWN; return VK_ERROR_UNKNOWN;
} }
@ -506,16 +630,17 @@ VkResult ui_lua_load_container(
lua_setupvalue(L, -3, 1); lua_setupvalue(L, -3, 1);
// Ref the env first so the chunk (now at the top) can be called // Ref the env first so the chunk (now at the top) can be called
c->script_env = luaL_ref(L, LUA_REGISTRYINDEX); int script_env = luaL_ref(L, LUA_REGISTRYINDEX);
// Top-level script code declares elements via ui.* during execution // No container exists yet — the script creates its own via
set_registry_pointers(L, c, ui, gpu); // ui.create_overlay, whenever it wants one
ui_lua_set_current_script(L, script_env);
set_registry_pointers(L, NULL, ui, gpu);
if(lua_pcall(L, 0, 0, 0) != LUA_OK) { if(lua_pcall(L, 0, 0, 0) != LUA_OK) {
fprintf(stderr, "ui_lua_load_container: %s\n", lua_tostring(L, -1)); fprintf(stderr, "ui_lua_run_script: %s\n", lua_tostring(L, -1));
lua_pop(L, 1); lua_pop(L, 1);
luaL_unref(L, LUA_REGISTRYINDEX, c->script_env); luaL_unref(L, LUA_REGISTRYINDEX, script_env);
c->script_env = 0;
return VK_ERROR_UNKNOWN; return VK_ERROR_UNKNOWN;
} }
@ -533,6 +658,7 @@ static bool ui_lua_begin_dispatch(
if(c == NULL || c->script_env == 0) return false; if(c == NULL || c->script_env == 0) return false;
set_registry_pointers(L, c, ui, gpu); set_registry_pointers(L, c, ui, gpu);
ui_lua_set_current_script(L, c->script_env);
lua_rawgeti(L, LUA_REGISTRYINDEX, c->script_env); lua_rawgeti(L, LUA_REGISTRYINDEX, c->script_env);
lua_getfield(L, -1, handler); lua_getfield(L, -1, handler);
@ -556,23 +682,16 @@ static bool ui_lua_finish_dispatch(lua_State* L, const char* handler, int nargs)
return consumed; return consumed;
} }
VkResult ui_lua_call( Container* ui_lua_current_container(lua_State* L) {
UIContext* ui, return registry_pointer(L, UI_LUA_CONTAINER);
RenderContext* gpu, }
void ui_lua_set_current(
lua_State* L,
Container* c, Container* c,
const char* function, UIContext* ui,
const char* argument) { RenderContext* gpu) {
lua_State* L = ui->lua; set_registry_pointers(L, c, ui, gpu);
if(!ui_lua_begin_dispatch(L, ui, gpu, c, function)) {
return VK_ERROR_UNKNOWN;
}
lua_pushstring(L, argument);
if(lua_pcall(L, 1, 0, 0) != LUA_OK) {
fprintf(stderr, "%s: %s\n", function, lua_tostring(L, -1));
lua_pop(L, 1);
return VK_ERROR_UNKNOWN;
}
return VK_SUCCESS;
} }
bool ui_lua_dispatch_button( bool ui_lua_dispatch_button(
@ -587,13 +706,14 @@ bool ui_lua_dispatch_button(
int mods) { int mods) {
lua_State* L = ui->lua; lua_State* L = ui->lua;
if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_button")) return false; if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_button")) return false;
push_overlay_handle(L, c);
push_element_handle(L, c, element); push_element_handle(L, c, element);
lua_pushnumber(L, x); lua_pushnumber(L, x);
lua_pushnumber(L, y); lua_pushnumber(L, y);
lua_pushinteger(L, button); lua_pushinteger(L, button);
lua_pushinteger(L, action); lua_pushinteger(L, action);
lua_pushinteger(L, mods); lua_pushinteger(L, mods);
return ui_lua_finish_dispatch(L, "on_button", 6); return ui_lua_finish_dispatch(L, "on_button", 7);
} }
bool ui_lua_dispatch_cursor( bool ui_lua_dispatch_cursor(
@ -605,10 +725,11 @@ bool ui_lua_dispatch_cursor(
float y) { float y) {
lua_State* L = ui->lua; lua_State* L = ui->lua;
if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_cursor")) return false; if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_cursor")) return false;
push_overlay_handle(L, c);
push_element_handle(L, c, element); push_element_handle(L, c, element);
lua_pushnumber(L, x); lua_pushnumber(L, x);
lua_pushnumber(L, y); lua_pushnumber(L, y);
return ui_lua_finish_dispatch(L, "on_cursor", 3); return ui_lua_finish_dispatch(L, "on_cursor", 4);
} }
bool ui_lua_dispatch_scroll( bool ui_lua_dispatch_scroll(
@ -620,10 +741,11 @@ bool ui_lua_dispatch_scroll(
double y) { double y) {
lua_State* L = ui->lua; lua_State* L = ui->lua;
if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_scroll")) return false; if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_scroll")) return false;
push_overlay_handle(L, c);
push_element_handle(L, c, element); push_element_handle(L, c, element);
lua_pushnumber(L, x); lua_pushnumber(L, x);
lua_pushnumber(L, y); lua_pushnumber(L, y);
return ui_lua_finish_dispatch(L, "on_scroll", 3); return ui_lua_finish_dispatch(L, "on_scroll", 4);
} }
bool ui_lua_dispatch_key( bool ui_lua_dispatch_key(
@ -636,11 +758,12 @@ bool ui_lua_dispatch_key(
int mods) { int mods) {
lua_State* L = ui->lua; lua_State* L = ui->lua;
if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_key")) return false; if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_key")) return false;
push_overlay_handle(L, c);
push_element_handle(L, c, element); push_element_handle(L, c, element);
lua_pushinteger(L, key); lua_pushinteger(L, key);
lua_pushinteger(L, action); lua_pushinteger(L, action);
lua_pushinteger(L, mods); lua_pushinteger(L, mods);
return ui_lua_finish_dispatch(L, "on_key", 4); return ui_lua_finish_dispatch(L, "on_key", 5);
} }
bool ui_lua_dispatch_text( bool ui_lua_dispatch_text(
@ -651,9 +774,10 @@ bool ui_lua_dispatch_text(
unsigned int codepoint) { unsigned int codepoint) {
lua_State* L = ui->lua; lua_State* L = ui->lua;
if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_text")) return false; if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_text")) return false;
push_overlay_handle(L, c);
push_element_handle(L, c, element); push_element_handle(L, c, element);
lua_pushinteger(L, codepoint); lua_pushinteger(L, codepoint);
return ui_lua_finish_dispatch(L, "on_text", 2); return ui_lua_finish_dispatch(L, "on_text", 3);
} }
bool ui_lua_dispatch_deselect( bool ui_lua_dispatch_deselect(
@ -663,6 +787,7 @@ bool ui_lua_dispatch_deselect(
uint32_t element) { uint32_t element) {
lua_State* L = ui->lua; lua_State* L = ui->lua;
if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_deselect")) return false; if(!ui_lua_begin_dispatch(L, ui, gpu, c, "on_deselect")) return false;
push_overlay_handle(L, c);
push_element_handle(L, c, element); push_element_handle(L, c, element);
return ui_lua_finish_dispatch(L, "on_deselect", 1); return ui_lua_finish_dispatch(L, "on_deselect", 2);
} }

@ -17,7 +17,7 @@ static int failures = 0;
// land a synthetic click on a given hex vertex/center. // land a synthetic click on a given hex vertex/center.
void world_to_cursor(ClientContext* context, vec3 world_point, double cursor[2]) { void world_to_cursor(ClientContext* context, vec3 world_point, double cursor[2]) {
mat4 view_proj; mat4 view_proj;
glm_mat4_mul(context->hex.data.proj, context->hex.data.view, view_proj); glm_mat4_mul(context->camera.proj, context->camera.view, view_proj);
vec4 point = {world_point[0], world_point[1], world_point[2], 1.0f}; vec4 point = {world_point[0], world_point[1], world_point[2], 1.0f};
vec4 clip; vec4 clip;
@ -34,17 +34,20 @@ void world_to_cursor(ClientContext* context, vec3 world_point, double cursor[2])
// matrices the real per-frame update would - required up front since a // matrices the real per-frame update would - required up front since a
// one-shot app_frame callback runs before app_main's own camera-update step. // one-shot app_frame callback runs before app_main's own camera-update step.
void setup_camera(ClientContext* context) { void setup_camera(ClientContext* context) {
context->position[0] = 0; Camera* camera = &context->camera;
context->position[1] = 0; camera->position[0] = 0;
context->position[2] = 0; camera->position[1] = 0;
camera->position[2] = 0;
// rotation[1] intentionally avoids M_PI/2 (straight down), which is // rotation[1] intentionally avoids M_PI/2 (straight down), which is
// parallel to the {0,1,0} up vector and singular for glm_lookat. // parallel to the {0,1,0} up vector and singular for glm_lookat.
context->rotation[0] = 0; camera->rotation[0] = 0;
context->rotation[1] = 1.3f; camera->rotation[1] = 1.3f;
context->distance = 10; camera->distance = 10;
update_hex_proj(&context->render, &context->hex); camera_update_proj(camera, (float)context->render.swapchain_extent.width/(float)context->render.swapchain_extent.height);
update_hex_view(context->position, context->rotation, context->distance, &context->render, &context->hex); camera_update_view(camera);
camera_sync_gpu(camera, &context->render);
update_hex_picking_inverse(camera, &context->hex);
} }
void click(ClientContext* context, vec3 world_point, int mods) { void click(ClientContext* context, vec3 world_point, int mods) {
@ -53,10 +56,18 @@ void click(ClientContext* context, vec3 world_point, int mods) {
editor_button_callback(context, (float)cursor[0], (float)cursor[1], GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, mods); editor_button_callback(context, (float)cursor[0], (float)cursor[1], GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, mods);
} }
// editor_set_mode only writes the editor.mode property; the reset-selection
// reaction lives in a bus subscriber, so a real mode change requires a
// drain to take effect - same as it would via a key press in the real app.
void set_mode(ClientContext* context, EditorMode mode) {
editor_set_mode(context, mode);
event_bus_drain(&context->events, &context->ui, &context->render, 0);
}
void test_hex_mode_click_adds(ClientContext* context) { void test_hex_mode_click_adds(ClientContext* context) {
EditorData* data = context->app_data; EditorData* data = context->app_data;
clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); set_mode(context, MODE_NONE);
enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); set_mode(context, MODE_HEX);
vec3 hex0_center = {0, 0, 0}; vec3 hex0_center = {0, 0, 0};
click(context, hex0_center, 0); click(context, hex0_center, 0);
@ -73,8 +84,8 @@ void test_hex_mode_click_adds(ClientContext* context) {
void test_hex_mode_ctrl_click_removes(ClientContext* context) { void test_hex_mode_ctrl_click_removes(ClientContext* context) {
EditorData* data = context->app_data; EditorData* data = context->app_data;
clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); set_mode(context, MODE_NONE);
enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); set_mode(context, MODE_HEX);
vec3 hex0_center = {0, 0, 0}; vec3 hex0_center = {0, 0, 0};
vec3 hex1_center = {hex_starts[0][0], hex_starts[0][1], hex_starts[0][2]}; vec3 hex1_center = {hex_starts[0][0], hex_starts[0][1], hex_starts[0][2]};
@ -90,8 +101,8 @@ void test_hex_mode_ctrl_click_removes(ClientContext* context) {
void test_vertex_mode_edge_only_picking(ClientContext* context) { void test_vertex_mode_edge_only_picking(ClientContext* context) {
EditorData* data = context->app_data; EditorData* data = context->app_data;
clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); set_mode(context, MODE_NONE);
enter_vertex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); set_mode(context, MODE_VERTEX);
// Clicking dead center of the hex would, without edge-only picking, hit // Clicking dead center of the hex would, without edge-only picking, hit
// vertex 0 (the center) and select nothing. // vertex 0 (the center) and select nothing.
@ -104,8 +115,8 @@ void test_vertex_mode_edge_only_picking(ClientContext* context) {
void test_vertex_mode_click_adds_and_ctrl_removes(ClientContext* context) { void test_vertex_mode_click_adds_and_ctrl_removes(ClientContext* context) {
EditorData* data = context->app_data; EditorData* data = context->app_data;
clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); set_mode(context, MODE_NONE);
enter_vertex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); set_mode(context, MODE_VERTEX);
vec3 v1 = {hex_vertices[0][0], hex_vertices[0][1], hex_vertices[0][2]}; vec3 v1 = {hex_vertices[0][0], hex_vertices[0][1], hex_vertices[0][2]};
vec3 v4 = {hex_vertices[3][0], hex_vertices[3][1], hex_vertices[3][2]}; vec3 v4 = {hex_vertices[3][0], hex_vertices[3][1], hex_vertices[3][2]};
@ -120,18 +131,18 @@ void test_vertex_mode_click_adds_and_ctrl_removes(ClientContext* context) {
void test_mode_switch_resets_selection(ClientContext* context) { void test_mode_switch_resets_selection(ClientContext* context) {
EditorData* data = context->app_data; EditorData* data = context->app_data;
enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); set_mode(context, MODE_HEX);
click(context, (vec3){0, 0, 0}, 0); click(context, (vec3){0, 0, 0}, 0);
CHECK(data->selected_count == 1, "setup: a hex should be selected before the mode switch"); CHECK(data->selected_count == 1, "setup: a hex should be selected before the mode switch");
enter_vertex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); set_mode(context, MODE_VERTEX);
CHECK(data->selected_count == 0, "switching modes should reset the selection"); CHECK(data->selected_count == 0, "switching modes should reset the selection");
} }
void test_empty_space_click_is_noop(ClientContext* context) { void test_empty_space_click_is_noop(ClientContext* context) {
EditorData* data = context->app_data; EditorData* data = context->app_data;
clear_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); set_mode(context, MODE_NONE);
enter_hex_mode(data, context, &(ModeKey){0}, GLFW_PRESS, 0); set_mode(context, MODE_HEX);
// A cursor coordinate this far outside the window unprojects to a ray // A cursor coordinate this far outside the window unprojects to a ray
// angled far off the view axis - guaranteed to miss the small hex region // angled far off the view axis - guaranteed to miss the small hex region
@ -140,11 +151,39 @@ void test_empty_space_click_is_noop(ClientContext* context) {
CHECK(data->selected_count == 0, "clicking empty space should not select anything"); CHECK(data->selected_count == 0, "clicking empty space should not select anything");
} }
void test_frame_callback(ClientContext* context) { // Regression test: editor_startup must compute the initial view matrix
// itself. It used to rely on init_vulkan's first-frame framebuffer_recreated
// flag incidentally running the (then-combined) resize+camera update block;
// once camera integration moved into editor_frame_callback and became
// gated purely on spin/velocity/zoom, that incidental trigger disappeared,
// and the hex region was invisible until the camera was first moved. Must
// run before setup_camera, which would otherwise mask the bug by computing
// its own view matrix first.
//
// ClientContext is zero-initialized (run_app's memset), so all-zero - not
// identity - is the untouched/broken sentinel to check against; a real
// glm_lookat result for any non-degenerate camera can't be all-zero.
void test_initial_camera_view_is_computed(ClientContext* context) {
bool is_zero = true;
for(int col = 0; col < 4 && is_zero; col++) {
for(int row = 0; row < 4; row++) {
if(context->camera.view[col][row] != 0.0f) {
is_zero = false;
break;
}
}
}
CHECK(!is_zero, "editor_startup computes the initial view matrix without requiring camera movement");
}
void test_frame_callback(ClientContext* context, double delta_time) {
(void)delta_time;
static bool ran = false; static bool ran = false;
if(ran) return; if(ran) return;
ran = true; ran = true;
test_initial_camera_view_is_computed(context);
setup_camera(context); setup_camera(context);
test_hex_mode_click_adds(context); test_hex_mode_click_adds(context);