From 80bad9aad5c4688ccb0dccf976c014d2db66a526 Mon Sep 17 00:00:00 2001 From: Riyyi Date: Sun, 6 Sep 2026 23:35:21 +0200 Subject: [PATCH] First working version of hot-reloading feature --- build.sh | 47 +++++++++---- game/game.odin | 75 +++++++++++++++++++++ src/core/settings.odin | 21 ++++++ src/glfw.odin | 68 +++++++++++++++++-- src/hot_reload/hot_reload.odin | 120 +++++++++++++++++++++++++++------ src/main.odin | 24 +++++-- src/wgpu.odin | 2 + 7 files changed, 314 insertions(+), 43 deletions(-) create mode 100644 src/core/settings.odin diff --git a/build.sh b/build.sh index 5305c91..62c3767 100755 --- a/build.sh +++ b/build.sh @@ -11,28 +11,47 @@ VERSION="dev-$(date -u '+%Y-%m-%d')-$(git rev-parse --short HEAD)" ./scripts/wgpu-init.sh # ------------------------------------------ +# Game compile mkdir -p build # Game.dll odin build game/ -show-timings \ - -collection:sindri=src \ - -build-mode:dynamic \ - -out:build/game -microarch:native -define:VERSION="$VERSION-debug" -debug "$@" + -collection:sindri=src \ + -build-mode:dynamic \ + -out:build/game_tmp -microarch:native -define:VERSION="$VERSION-debug" -debug "$@" + +# Need to use a temp file on Linux/macOS because it first writes an empty file, +# which the engine will load before it is actually fully written. +[ "$(uname -s)" = "Darwin" ] && LIB_EXT="dylib" || LIB_EXT="so" +[ -f "build/game_tmp.$LIB_EXT" ] || { + echo "error: build produced no game lib.$LIB_EXT" >&2 + exit 1 +} +mv -f "build/game_tmp.$LIB_EXT" "build/game.$LIB_EXT" + +# ------------------------------------------ +# Engine compile + +# If the executable is already running, then don't try to build and start it. +if pgrep -x $PROJECT >/dev/null; then + echo "Hot reloading..." + exit 0 +fi if [ "$1" = "debug" ]; then - shift + shift - odin build src/ -show-timings \ - -collection:sindri=src \ - -collection:gram=vendor/gram/src \ - -collection:wgpu=vendor \ - -out:build/$PROJECT -microarch:native -use-separate-modules -define:VERSION="$VERSION-debug" -debug "$@" - exit 0 + odin build src/ -show-timings \ + -collection:sindri=src \ + -collection:gram=vendor/gram/src \ + -collection:wgpu=vendor \ + -out:build/$PROJECT -microarch:native -use-separate-modules -define:VERSION="$VERSION-debug" -debug "$@" + exit 0 fi odin build src/ -show-timings \ - -collection:sindri=src \ - -collection:gram=vendor/gram/src \ - -collection:wgpu=vendor \ - -out:build/$PROJECT -microarch:native -o:speed -define:VERSION="$VERSION" "$@" + -collection:sindri=src \ + -collection:gram=vendor/gram/src \ + -collection:wgpu=vendor \ + -out:build/$PROJECT -microarch:native -o:speed -define:VERSION="$VERSION" "$@" diff --git a/game/game.odin b/game/game.odin index af93930..9725ff0 100644 --- a/game/game.odin +++ b/game/game.odin @@ -3,9 +3,72 @@ package game import "core:time" import "core:fmt" +import "sindri:core" + +// ----------------------------------------- + +Game_Memory :: struct { +} + +g: ^Game_Memory + +// ----------------------------------------- + +@(export) +memory :: proc() -> rawptr { + return g +} + +@(export) +memory_free :: proc() { + free(g) +} + +@(export) +memory_size :: proc() -> int { + return size_of(Game_Memory) +} + +@(export) +memory_set :: proc(mem: rawptr) { + g = (^Game_Memory)(mem) + + // Here you can also set your own global variables. A good idea is to make + // your global variables into pointers that point to something inside `g`. +} + +@(export) +settings :: proc() -> core.Settings { + return core.Settings { + width = 960, + height = 540, + title = "WGPU Native Triangle", + mode = core.WindowMode.Windowed, + refresh = 60, + vsync = true, + } +} + +@(export) +init_once :: proc() { + fmt.println("init once") +} + @(export) init :: proc() { fmt.println("hello from .dll!") + + g = new(Game_Memory) +} + +@(export) +update :: proc(dt: f32) { + fmt.println("dt:", dt) +} + +@(export) +destroy :: proc() { + fmt.println("bye") } start := time.tick_now() @@ -18,3 +81,15 @@ should_close :: proc() -> bool { return false } + +@(export) +force_reload :: proc() -> bool { + // TODO: read keypress + return false +} + +@(export) +force_restart :: proc() -> bool { + // TODO: read keypress + return false +} diff --git a/src/core/settings.odin b/src/core/settings.odin new file mode 100644 index 0000000..4581cdd --- /dev/null +++ b/src/core/settings.odin @@ -0,0 +1,21 @@ +package core + +// ----------------------------------------- + +Settings :: struct { + width: u16, + height: u16, + title: string, + mode: WindowMode, + refresh: u16, + vsync: bool, + // TODO: + // windowed resizable y/n + // exit key (ex: escape) +} + +WindowMode :: enum u8 { + Windowed = 0, + Borderless = 1, + Fullscreen = 2, +} diff --git a/src/glfw.odin b/src/glfw.odin index 1c5a7c8..3b77fcc 100644 --- a/src/glfw.odin +++ b/src/glfw.odin @@ -1,24 +1,31 @@ package sindri +import "core:strings" import "vendor:glfw" import "wgpu:wgpu" import "wgpu:wgpu/glfwglue" +import "sindri:core" + +// ----------------------------------------- + OS :: struct { window: glfw.WindowHandle, } -os_init :: proc() { +// ----------------------------------------- + +os_init :: proc(settings: core.Settings) { if !glfw.Init() { panic("[glfw] init failure") } glfw.WindowHint(glfw.CLIENT_API, glfw.NO_API) state.os.window = glfw.CreateWindow( - 960, - 540, - "WGPU Native Triangle", + i32(settings.width), + i32(settings.height), + strings.unsafe_string_to_cstring(settings.title), nil, nil, ) @@ -26,6 +33,59 @@ os_init :: proc() { glfw.SetFramebufferSizeCallback(state.os.window, size_callback) } +os_set_monitor :: proc(settings: core.Settings) { + monitor := glfw.GetPrimaryMonitor() + x_pos: i32 + y_pos: i32 + width := i32(settings.width) + height := i32(settings.height) + target_monitor: glfw.MonitorHandle + + mode := glfw.GetVideoMode(monitor) + + switch settings.mode { + case .Fullscreen: + target_monitor = monitor + case .Borderless: + // FIXME: On macOS, borderless also fills the notch area + + // Monitor origin on the virtual desktop + x_pos, y_pos = glfw.GetMonitorPos(monitor) + + width = mode.width + height = mode.height + + glfw.SetWindowAttrib( + state.os.window, + glfw.DECORATED, + i32(glfw.FALSE), + ) + case .Windowed: + // Put window in the center of the monitor + x_pos = (mode.width - width) / 2 + y_pos = (mode.height - height) / 2 + + glfw.SetWindowAttrib( + state.os.window, + glfw.DECORATED, + i32(glfw.TRUE), + ) + } + + refresh := settings.vsync ? mode.refresh_rate : i32(settings.refresh) + if refresh == 0 do refresh = glfw.DONT_CARE + + glfw.SetWindowMonitor( + state.os.window, + target_monitor, + x_pos, + y_pos, + width, + height, + refresh, + ) +} + os_should_close :: proc() -> bool { return bool(glfw.WindowShouldClose(state.os.window)) } diff --git a/src/hot_reload/hot_reload.odin b/src/hot_reload/hot_reload.odin index 8b537fc..230bcea 100644 --- a/src/hot_reload/hot_reload.odin +++ b/src/hot_reload/hot_reload.odin @@ -3,6 +3,9 @@ package hot_reload import "core:dynlib" import "core:fmt" import "core:os" +import "core:time" + +import "sindri:core" when ODIN_OS == .Windows { LIB_EXT :: ".dll" @@ -33,31 +36,39 @@ Error :: union #shared_nil { } Game_API :: struct { - lib: dynlib.Library, - init: proc(), - update: proc(), - render: proc(), - destroy: proc(), - should_close: proc() -> bool, - api_version: int, // iteration of the game lib + lib: dynlib.Library, + memory: proc() -> rawptr, + memory_free: proc(), + memory_size: proc() -> int, + memory_set: proc(mem: rawptr), + settings: proc() -> core.Settings, + init_once: proc(), + init: proc(), + update: proc(_: f32), + render: proc(), + destroy: proc(), + should_close: proc() -> bool, + force_reload: proc() -> bool, + force_restart: proc() -> bool, + modification_time: time.Time, + api_version: int, // iteration of the game lib } // ----------------------------------------- -hot_reload_init :: proc() -> (state: Hot_Reload, error: Error) { +hot_reload_init :: proc() -> (hr: Hot_Reload, error: Error) { api: Game_API - api.api_version = -1 api_ok := load_game_lib(&api) if !api_ok { - fmt.println("Failed to load Game API") + fmt.println("error: failed to load Game API") return {}, .Load_Game_Lib_Failed } - state.loaded_libs = make([dynamic]Game_API) - append(&state.loaded_libs, api) + hr.loaded_libs = make([dynamic]Game_API, 0, 0, context.allocator) + append(&hr.loaded_libs, api) - return state, nil + return hr, nil } hot_reload_destroy :: proc(state: ^Hot_Reload) { @@ -84,7 +95,8 @@ copy_lib :: proc(to: string) -> bool { copy_err := os.copy_file(to, GAME_LIB_PATH) if copy_err != nil { fmt.printfln( - "error: Failed to copy " + GAME_LIB_PATH + " to {0}: %v", + "error: failed to copy {0} to {1}: {2:v}", + GAME_LIB_PATH, to, copy_err, ) @@ -96,8 +108,16 @@ copy_lib :: proc(to: string) -> bool { // Load game lib symbols load_game_lib :: proc(api: ^Game_API) -> bool { - // Load next iteration - api.api_version += 1 + mod_time, mod_time_err := os.last_write_time_by_name(GAME_LIB_PATH) + if mod_time_err != os.ERROR_NONE { + fmt.printfln( + "error: failed getting last write time of {0}, error code: {1}", + GAME_LIB_PATH, + mod_time_err, + ) + return false + } + api.modification_time = mod_time lib_name := fmt.tprintf( GAME_LIB_DIR + "game_{0}" + LIB_EXT, @@ -106,9 +126,12 @@ load_game_lib :: proc(api: ^Game_API) -> bool { copy_lib(lib_name) or_return // Match the names of the fields in Game_API to symbols in the game DLL - _, ok := dynlib.initialize_symbols(api, GAME_LIB_PATH, "", "lib") + _, ok := dynlib.initialize_symbols(api, lib_name, "", "lib") if !ok { - fmt.printfln("Failed initializing symbols: {0}", dynlib.last_error()) + fmt.printfln( + "error: failed initializing symbols: {0}", + dynlib.last_error(), + ) } return ok @@ -119,7 +142,7 @@ unload_game_lib :: proc(api: ^Game_API) { if api.lib != nil { if !dynlib.unload_library(api.lib) { fmt.eprintfln( - "error: Failed unloading lib: {0}", + "error: failed unloading lib: {0}", dynlib.last_error(), ) } @@ -130,9 +153,66 @@ unload_game_lib :: proc(api: ^Game_API) { ) != nil { fmt.printfln( - "error: Failed to remove {0}game_{1}" + LIB_EXT + " copy", + "error: failed to remove {0}game_{1}{2} copy", GAME_LIB_DIR, api.api_version, + LIB_EXT, ) } } + +reload_game_lib :: proc(hr: ^Hot_Reload) -> (lib: ^Game_API, err: Error) { + api := active_lib(hr) + force_reload := api.force_reload() + force_restart := api.force_restart() + reload := force_reload || force_restart + + lib_mod, lib_mod_err := os.last_write_time_by_name(GAME_LIB_PATH) + // TODO: Handle error + if lib_mod_err == os.ERROR_NONE && api.modification_time != lib_mod { + reload = true + } + + if !reload do return api, nil + + new_api: Game_API + new_api.api_version = api.api_version + 1 + new_api_ok := load_game_lib(&new_api) + if !new_api_ok { + fmt.println("error: failed to load Game API") + return nil, .Load_Game_Lib_Failed + } + + force_restart = force_restart || api.memory_size() != new_api.memory_size() + + if !force_restart { + // This does the normal hot reload + + // Note that we don't unload the old game APIs because that + // would unload the lib. The lib can contain stored info + // such as string literals. The old libs are only unloaded + // on a full reset or on shutdown. + memory := api.memory() + new_api.memory_set(memory) + } else { + // This does a full reset. That's basically like opening and + // closing the game, without having to restart the executable. + // + // You end up in here if the game requests a full reset OR + // if the size of the game memory has changed. That would + // probably lead to a crash anyways. + + api.memory_free() + + for &g in hr.loaded_libs { + unload_game_lib(&g) + } + clear(&hr.loaded_libs) + + new_api.init() + } + + append(&hr.loaded_libs, new_api) + + return active_lib(hr), nil +} diff --git a/src/main.odin b/src/main.odin index c6ab779..7e059d7 100644 --- a/src/main.odin +++ b/src/main.odin @@ -17,24 +17,38 @@ main :: proc() { defer hot_reload.hot_reload_destroy(&hr) api := hot_reload.active_lib(&hr) - api.init() + settings := api.settings() // Initialize Window - os_init() + os_init(settings) + os_set_monitor(settings) defer os_destroy() // Initialize GPU resources instance_init() defer instance_destroy() - gt: f32 + api.init_once() + api.init() + + gt: f64 = 0 + dt: f32 for !os_should_close() && !hot_reload.should_close(&hr) { start := time.tick_now() os_poll_events() - frame(gt) + api.update(dt) - gt = f32(time.duration_seconds(time.tick_since(start))) + frame(dt) + + dt = f32(time.duration_seconds(time.tick_since(start))) + gt += f64(dt) + + // Hot reload + api, err = hot_reload.reload_game_lib(&hr) + if err != nil do break } + + api.destroy() } diff --git a/src/wgpu.odin b/src/wgpu.odin index 2870a42..e6bba39 100644 --- a/src/wgpu.odin +++ b/src/wgpu.odin @@ -147,6 +147,8 @@ instance_destroy :: proc() { resize :: proc "c" () { context = state.ctx + if state.surface == nil || state.device == nil do return + state.config.width, state.config.height = os_get_framebuffer_size() wgpu.SurfaceConfigure(state.surface, &state.config) }