Author SHA1 Message Date
Riyyi 6feb0c1575 Deduplicate multiple mesh instances 2026-10-31 22:01:10 +01:00
Riyyi 148b511009 Render multiple objects 2026-10-31 18:23:23 +01:00
Riyyi af063209ff Move vector types to base layer 2026-10-29 21:38:59 +01:00
Riyyi 163aa3052b Add basic reflection to wgpu struct and vertex attributes 2026-10-26 22:06:30 +01:00
Riyyi 426504c748 Add obj model loading and suzanne test model 2026-10-26 15:14:04 +01:00
Riyyi 30de42caca Add texture sampling 2026-10-17 12:55:59 +02:00
Riyyi 31005e0861 Add depth buffer 2026-10-14 23:10:33 +02:00
Riyyi d12118e80a Add camera uniform 2026-10-13 22:03:18 +02:00
Riyyi 8b30fa4e7c Basic vertex and index buffer 2026-10-12 18:59:50 +02:00
Riyyi 3549b0be95 Initial version of single binary hot-reloading 2026-10-12 11:11:51 +02:00
18 changed files with 3823 additions and 308 deletions
+55
View File
@@ -0,0 +1,55 @@
# Simple textured cube
# 8 vertices, 6 faces (12 triangles), per-face UVs
o Cube
# Positions
v -1.0 -1.0 1.0
v 1.0 -1.0 1.0
v 1.0 1.0 1.0
v -1.0 1.0 1.0
v -1.0 -1.0 -1.0
v 1.0 -1.0 -1.0
v 1.0 1.0 -1.0
v -1.0 1.0 -1.0
# Texture coordinates
vt 0.0 0.0
vt 1.0 0.0
vt 1.0 1.0
vt 0.0 1.0
# Normals
vn 0.0 0.0 1.0
vn 0.0 0.0 -1.0
vn 1.0 0.0 0.0
vn -1.0 0.0 0.0
vn 0.0 1.0 0.0
vn 0.0 -1.0 0.0
# Faces (front, back, right, left, top, bottom)
# Each face uses two triangles: (0,1,2) and (0,2,3)
# Front (+Z)
f 1/1/1 2/2/1 3/3/1
f 1/1/1 3/3/1 4/4/1
# Back (-Z)
f 6/1/2 5/2/2 8/3/2
f 6/1/2 8/3/2 7/4/2
# Right (+X)
f 2/1/3 6/2/3 7/3/3
f 2/1/3 7/3/3 3/4/3
# Left (-X)
f 5/1/4 1/2/4 4/3/4
f 5/1/4 4/3/4 8/4/4
# Top (+Y)
f 4/1/5 3/2/5 7/3/5
f 4/1/5 7/3/5 8/4/5
# Bottom (-Y)
f 5/1/6 6/2/6 2/3/6
f 5/1/6 2/3/6 1/4/6
+2027
View File
File diff suppressed because it is too large Load Diff
+34 -29
View File
@@ -32,8 +32,6 @@ PROJECT_NAME :: "sindri"
BUILD_DIRECTORY :: "out/"
GAME_SRC_DIR :: "game/"
GAME_LIB_PATH :: BUILD_DIRECTORY + "game" + LIB_EXT
GAME_LIB_TMP_PATH :: BUILD_DIRECTORY + "game_tmp" + LIB_EXT
ENGINE_DIR :: "src/"
ENGINE_OUTPUT :: BUILD_DIRECTORY + PROJECT_NAME
@@ -47,6 +45,7 @@ Options :: struct {
debug: bool `args:"optional" usage:"Build project in debug-mode."`,
release: bool `args:"optional" usage:"Build project in release-mode."`,
run: bool `args:"optional" usage:"Run project."`,
hot_reload: bool `args:"optional" usage:"Hot-reload project."`,
wgpu_init: bool `args:"optional" usage:"Prepare wgpu-native."`,
wgpu_version: string `args:"optional" usage:"Override the wgpu release version to init."`,
fmt: bool `args:"optional" usage:"Run odinfmt."`,
@@ -77,6 +76,7 @@ main :: proc() {
if opts.debug do build_project(true, opts.overflow[:])
if opts.release do build_project(false, opts.overflow[:])
if opts.run do run_project(opts.overflow[:])
if opts.hot_reload do hot_reload(!opts.release, opts.overflow[:])
if opts.wgpu_init do wgpu_init(opts.wgpu_version)
if opts.fmt do format_source()
if opts.pre_commit do pre_commit()
@@ -91,17 +91,12 @@ main :: proc() {
// Build project
// Build the game library ('game/') and the engine ('src/')
build_project :: proc(debug: bool, odin_extra_args: []string) {
build_project :: proc(debug: bool, extra_args: []string) {
version := project_version()
// Setup compiled wgpu binary
wgpu_init()
// ----------------------------------------
// Game compile
build_game(debug, version, odin_extra_args)
// ----------------------------------------
// Engine compile
@@ -111,7 +106,7 @@ build_project :: proc(debug: bool, odin_extra_args: []string) {
return
}
build_engine(debug, version, odin_extra_args)
build_engine(debug, version, extra_args)
}
// Shared builder between game/engine
@@ -124,6 +119,7 @@ build_common :: proc(
b := brokkr.builder_make()
brokkr.build(&b, dir)
brokkr.append(&b, "-show-timings")
brokkr.collection(&b, "game", ".")
brokkr.collection(&b, PROJECT_NAME, "src")
brokkr.collection(&b, "gram", "vendor/gram/src")
brokkr.collection(&b, "wgpu", "vendor")
@@ -144,24 +140,6 @@ build_common :: proc(
return b
}
// Build the game as a dynamic library loaded by the engine
build_game :: proc(debug: bool, version: string, extra_args: []string) {
b := build_common(GAME_SRC_DIR, debug, version, extra_args)
defer brokkr.builder_destroy(&b)
brokkr.mode(&b, .Dynamic)
// Use a temp file because it first writes an empty file, which the engine
// would load before it is actually fully written
brokkr.out(&b, GAME_LIB_TMP_PATH)
if err := brokkr.execute(&b); err != nil {
os.exit(1)
}
if !os.is_file(GAME_LIB_TMP_PATH) {
die("build produced no game lib %q", GAME_LIB_TMP_PATH)
}
os.rename(GAME_LIB_TMP_PATH, GAME_LIB_PATH)
}
// Build the engine executable
build_engine :: proc(debug: bool, version: string, extra_args: []string) {
b := build_common(ENGINE_DIR, debug, version, extra_args)
@@ -174,8 +152,7 @@ build_engine :: proc(debug: bool, version: string, extra_args: []string) {
run_project :: proc(extra_args: []string) {
// Build project if it wasnt already
if !os.exists(ENGINE_OUTPUT) ||
!os.exists(GAME_LIB_PATH) { build_project(true, {}) }
if !os.exists(output_path(ENGINE_OUTPUT)) do build_project(true, {})
cmd := []string{output_path(ENGINE_OUTPUT)}
line := slice.concatenate(
@@ -206,6 +183,34 @@ run_project :: proc(extra_args: []string) {
os.exit(res.exit_code)
}
hot_reload :: proc(debug: bool, extra_args: []string) {
if engine_is_running() {
process := output_path(PROJECT_NAME)
when ODIN_OS == .Windows {
// Ask process to shut down gracefully (equivalent to SIGTERM)
_, ok, _ := exec([]string{"taskkill", "/IM", process + ".exe"})
if !ok {
die("hot reload: failed to terminate running %s", process)
}
} else {
_, ok, _ := exec([]string{"pkill", "-TERM", "-x", process})
if !ok {
die(
"hot reload: failed to send SIGTERM to running %s",
process,
)
}
}
// Wait for process to terminate
for engine_is_running() {
time.sleep(time.Millisecond * 20)
}
}
run_project(extra_args)
}
// -----------------------------------------
// wgpu setup
+47
View File
@@ -0,0 +1,47 @@
# Hot Reloading
Instead of having the engine as an exe and game as a dll, and having to jump
through hoops to share libraries internal state between them we do it
differently. The engine and game live in the same binary, compiled fully each
time. We rely mostly on static memory, and register these memory regions. Then
when we want to hot reload: we send a close signal to the running instance, it
writes a snapshot of the active memory to disk, instance closes, recompile,
restart the application, it restores the memory from the snapshot.
## Byte Layout On Disk
```odin
Snapshot_Header :: struct #packed #all_or_none {
magic_string: u32,
version: u16, // spec of the snapshot
app_version: [4 + time.MIN_YYYY_DATE_LEN + 8]u8, // example: `dev-2026-09-23-a4084b3`
region_count: u32,
}
Region_Header :: struct #packed #all_or_none {
name_size: u32,
data_size: u32,
}
```
[ Snapshot_Header ]
[ Region_Header ]
[ name bytes ]
[ data bytes ]
[ Region_Header ]
[ name bytes ]
[ data bytes ]
[ etc.. ]
## Diff Checking
Use hashing per memory segment to do a sanity diff-check.
```odin
import "core:crypto/hash"
data: []byte = ...
digest := hash.hash_bytes(hash.Algorithm.SHA256, data)
defer delete(digest)
```
+35 -30
View File
@@ -7,39 +7,42 @@ import "sindri:base"
import "sindri:core"
// -----------------------------------------
// Types
Game_Memory :: struct {
should_close: bool,
}
g: ^Game_Memory
Test_Memory :: struct {
asd: int,
qwe: int,
}
// -----------------------------------------
// Variables
@(export)
memory :: proc() -> rawptr {
return g
g: Game_Memory
t: Test_Memory
game_api := core.Game_API {
settings = settings,
init_once = init_once,
init = init,
update = update,
render = render,
destroy = destroy,
should_close = should_close,
force_reload = force_reload,
force_restart = force_restart,
}
@(export)
memory_free :: proc() {
free(g)
// -----------------------------------------
// Public functions
api :: proc() -> ^core.Game_API {
return &game_api
}
@(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() -> base.Settings {
return base.Settings {
width = 960,
@@ -51,36 +54,40 @@ settings :: proc() -> base.Settings {
}
}
@(export)
init_once :: proc() {
fmt.println("init once")
base.register_region("game_memory", &g, size_of(Game_Memory))
base.register_region("test_memory", &t, size_of(Test_Memory))
}
@(export)
init :: proc() {
fmt.println("hello from .dll!")
g = new(Game_Memory)
memory_set(g)
g.should_close = false
}
@(export)
update :: proc(dt: f32) {
fmt.println("dt:", dt)
if core.key_state(.Key_Escape) == .Press {
g.should_close = true
}
if core.key_state(.Key_F5) == .Press do t.asd = 555
if core.key_state(.Key_F6) == .Press do t.asd = 666
}
@(export)
render :: proc() { }
destroy :: proc() {
fmt.println("bye")
fmt.println(t.asd, t.qwe)
}
start := time.tick_now()
@(export)
should_close :: proc() -> bool {
elapsed := time.tick_since(start)
seconds := time.duration_seconds(elapsed)
@@ -89,12 +96,10 @@ should_close :: proc() -> bool {
return g.should_close
}
@(export)
force_reload :: proc() -> bool {
return core.key_state(.Key_F5) == .Press
}
@(export)
force_restart :: proc() -> bool {
return core.key_state(.Key_F6) == .Press
}
+2 -1
View File
@@ -1,7 +1,8 @@
{
"collections": [
{ "name": "game", "path": "." },
{ "name": "sindri", "path": "src" },
{ "name": "gram", "path": "vendor/gram/src" },
{ "name": "wgpu", "path": "vendor" }
{ "name": "wgpu", "path": "vendor" }
]
}
+3
View File
@@ -0,0 +1,3 @@
package base
should_close: bool
-2
View File
@@ -158,8 +158,6 @@ on_event :: proc(data: Event_Data) {
drain_event_queue :: proc() {
assert(event_bus.queue != nil)
fmt.println("toby!")
for &event in event_bus.queue {
#partial switch e in event.data {
+265 -194
View File
@@ -1,231 +1,302 @@
package base
import "core:c"
import "core:dynlib"
import "base:runtime"
import "core:crypto/hash"
import "core:fmt"
import "core:mem"
import "core:os"
import "core:strings"
import "core:time"
when ODIN_OS == .Windows {
LIB_EXT :: ".dll"
} else when ODIN_OS == .Darwin {
LIB_EXT :: ".dylib"
} else {
LIB_EXT :: ".so"
}
SNAPSHOT_NAME :: "snapshot"
SNAPSHOT_EXTENSION :: "sndr"
GAME_LIB_DIR :: "out/"
GAME_LIB_PATH :: GAME_LIB_DIR + "game" + LIB_EXT
MAGIC_STRING :: 0x72646E73 // "rdns" -> "sndr" in little endian
HOT_RELOAD_VERSION :: 1
APP_VERSION_SIZE :: 4 + time.MIN_YYYY_DATE_LEN + 8 + 6 // example: `dev-2026-09-23-a4084b3-debug`
// -----------------------------------------
// Types
@(private)
Snapshot_Header :: struct #packed #all_or_none {
magic_string: u32,
version: u16, // spec of the snapshot
app_version: [APP_VERSION_SIZE]u8,
region_count: u32,
}
@(private)
Region_Header :: struct #packed #all_or_none {
name_size: u32,
data_size: u32,
}
@(private)
Memory_Region :: struct {
ptr_location: rawptr,
// TODO: How do we do hash compare without having to hash current memory every frame?
digest: []u8, // hash of this region in last snapshot
}
@(private)
Hot_Reload :: struct {
loaded_libs: [dynamic]Game_API,
}
Hot_Reload_Error :: enum u8 {
None = 0,
Load_Game_Lib_Failed = 1,
Unimplemented = 127,
Okay = None,
}
Error :: union #shared_nil {
Hot_Reload_Error,
}
Game_API :: struct {
lib: dynlib.Library,
memory: proc() -> rawptr,
memory_free: proc(),
memory_size: proc() -> int,
memory_set: proc(mem: rawptr),
settings: proc() -> 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
allocator: runtime.Allocator,
snapshot_path: string,
snapshot_header: Snapshot_Header,
region_headers: map[string]Region_Header, // hash of name:size
regions: map[string]Memory_Region, // hash of name:size
write_full_snapshot: bool,
}
// -----------------------------------------
// Variables
hot_reload_init :: proc() -> (hr: Hot_Reload, error: Error) {
api: Game_API
api_ok := load_game_lib(&api)
if !api_ok {
fmt.println("error: failed to load Game API")
return {}, .Load_Game_Lib_Failed
}
hr.loaded_libs = make([dynamic]Game_API, 0, 0, context.allocator)
append(&hr.loaded_libs, api)
return hr, nil
}
hot_reload_destroy :: proc(state: ^Hot_Reload) {
for &api in state.loaded_libs {
unload_game_lib(&api)
}
delete(state.loaded_libs)
}
@(private)
hr: Hot_Reload
// -----------------------------------------
// Constructor & destructor
active_lib :: proc(hr: ^Hot_Reload) -> ^Game_API {
return &hr.loaded_libs[len(hr.loaded_libs) - 1]
}
should_close :: proc(hr: ^Hot_Reload) -> bool {
api := active_lib(hr)
if api.lib == nil do return true
return api.should_close()
}
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 {0} to {1}: {2:v}",
GAME_LIB_PATH,
to,
copy_err,
)
return false
}
return true
}
// Load game lib symbols
load_game_lib :: proc(api: ^Game_API) -> bool {
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,
api.api_version,
hot_reload_init :: proc(
app_version: string,
allocator := context.allocator,
) -> os.Error {
// Get snapshot path
out := os.get_executable_directory(allocator) or_return
defer delete(out, allocator)
snapshot_path := fmt.tprintf(
"%v/%v.%v",
out,
SNAPSHOT_NAME,
SNAPSHOT_EXTENSION,
)
copy_lib(lib_name) or_return
snapshot_path_cpy := strings.clone(snapshot_path, allocator)
// Match the names of the fields in Game_API to symbols in the game DLL
_, ok := dynlib.initialize_symbols(api, lib_name, "", "lib")
if !ok {
fmt.printfln(
"error: failed initializing symbols: {0}",
dynlib.last_error(),
// Copy version string
version: [APP_VERSION_SIZE]u8
n := min(len(app_version), APP_VERSION_SIZE)
copy(version[:n], app_version[:n])
hr = {
allocator = allocator,
snapshot_path = snapshot_path_cpy,
snapshot_header = {
magic_string = MAGIC_STRING,
version = HOT_RELOAD_VERSION,
app_version = version,
region_count = 0,
},
region_headers = make(map[string]Region_Header, 1, allocator), // initial capacity for nil checks
regions = make(map[string]Memory_Region, 1, allocator), // initial capacity for nil checks
write_full_snapshot = true,
}
return nil
}
hot_reload_destroy :: proc() {
assert(
hr.region_headers != nil,
"[hot-reload] destroying uninitialized data",
)
assert(hr.regions != nil, "[hot-reload] destroying uninitialized data")
delete(hr.snapshot_path, hr.allocator)
for key, region in hr.region_headers do delete(key, hr.allocator)
for key, region in hr.regions {
if region.digest != nil do delete(region.digest, hr.allocator)
}
delete(hr.region_headers)
delete(hr.regions)
}
// -----------------------------------------
// Public functions
register_region :: proc(
name: string,
ptr_location: rawptr,
data_size: u32,
) -> os.Error {
assert(hr.region_headers != nil, "[hot-reload] uninitialized")
assert(hr.regions != nil, "[hot-reload] uninitialized")
assert(ptr_location != nil, "[hot_reload] invalid location")
assert(data_size > 0, "[hot_reload] invalid type size")
hash := fmt.tprintf("%v:%d", name, data_size)
assert(!(hash in hr.regions), "[hot_reload] double registration")
hash_cpy := strings.clone(hash, hr.allocator)
hr.region_headers[hash_cpy] = Region_Header {
name_size = cast(u32)len(name),
data_size = data_size,
}
hr.regions[hash_cpy] = Memory_Region {
ptr_location = ptr_location,
}
hr.snapshot_header.region_count += 1
hr.write_full_snapshot = true
return nil
}
read_snapshot :: proc() -> os.Error {
assert(hr.region_headers != nil, "[hot-reload] uninitialized")
assert(hr.regions != nil, "[hot-reload] uninitialized")
if !os.exists(hr.snapshot_path) {
hr.write_full_snapshot = true
return nil
}
snapshot := os.open(hr.snapshot_path, {.Read}) or_return
defer os.close(snapshot)
offset: i64
// Read snapshot header
snapshot_header: Snapshot_Header
header_bytes := mem.ptr_to_bytes(&snapshot_header)
os.read_at(snapshot, header_bytes, offset) or_return
offset += size_of(Snapshot_Header)
assert(
snapshot_header.magic_string == MAGIC_STRING,
"[hot-reload] unknown filetype",
)
assert(
snapshot_header.version == HOT_RELOAD_VERSION,
"[hot-reload] incompatible snapshot version",
)
region_header: Region_Header
for i in 0 ..< snapshot_header.region_count {
// Read region header
header_bytes := mem.ptr_to_bytes(&region_header)
os.read_at(snapshot, header_bytes, offset) or_return
offset += size_of(Region_Header)
// Read region name
name_bytes := make([]byte, region_header.name_size, hr.allocator)
defer delete(name_bytes, hr.allocator)
os.read_at(snapshot, name_bytes, offset) or_return
offset += cast(i64)region_header.name_size
name := transmute(string)name_bytes[:]
// Read region data
data_bytes := make([]byte, region_header.data_size, hr.allocator)
defer delete(data_bytes, hr.allocator)
os.read_at(snapshot, data_bytes, offset) or_return
offset += cast(i64)region_header.data_size
// Compare currently registered region with the loaded region
key := fmt.tprintf("%v:%d", name, region_header.data_size)
new_region_header, ok := hr.region_headers[key]
if !ok || region_header.data_size != new_region_header.data_size {
hr.write_full_snapshot = true
fmt.println("[hot-reload] bail loading region:", name)
continue
}
// Create digest and store in registered regions
digest := hash.hash_bytes(
hash.Algorithm.SHA256,
data_bytes,
hr.allocator,
)
} else {
// Odin's shared libraries don't register a constructor, so the lib's
// runtime init (@init procs like os.stdout setup) never runs on load.
// Call it explicitly.
// Windows doesn't need this: its dynamic build exports DllMain, which
// the OS calls on load and which runs _startup_runtime itself.
when ODIN_OS != .Windows {
Entry :: #type proc "c" () -> c.int
entry_ptr, entry_ok := dynlib.symbol_address(
api.lib,
"_odin_entry_point",
)
if entry_ok do (transmute(Entry)entry_ptr)()
}
}
region := &hr.regions[key]
region.digest = digest
return ok
}
// Unload game lib, used during full restarts
unload_game_lib :: proc(api: ^Game_API) {
if api.lib != nil {
if !dynlib.unload_library(api.lib) {
fmt.eprintfln(
"error: failed unloading lib: {0}",
dynlib.last_error(),
)
}
}
if os.remove(
fmt.tprintf(GAME_LIB_DIR + "game_{0}" + LIB_EXT, api.api_version),
) !=
nil {
fmt.printfln(
"error: failed to remove {0}game_{1}{2} copy",
GAME_LIB_DIR,
api.api_version,
LIB_EXT,
// Write snapshot memory to the memory region
dest := mem.byte_slice(
region.ptr_location,
cast(int)region_header.data_size,
)
copy(dest, data_bytes)
}
return nil
}
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
// Next write will be a full memory snapshot
set_write_full_snapshot :: proc() {
hr.write_full_snapshot = true
}
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
}
// Write memory snapshot, save on writes by diff-ing
write_snapshot :: proc() -> os.Error {
assert(hr.region_headers != nil, "[hot-reload] uninitialized")
assert(hr.regions != nil, "[hot-reload] uninitialized")
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)
snapshot: ^os.File
if hr.write_full_snapshot || !os.exists(hr.snapshot_path) {
snapshot = os.create(hr.snapshot_path) or_return
} 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.
snapshot = os.open(hr.snapshot_path, {.Read, .Write}) or_return
}
defer os.close(snapshot)
api.memory_free()
offset: i64
for &g in hr.loaded_libs {
unload_game_lib(&g)
// Write header
if hr.write_full_snapshot {
header_bytes := mem.ptr_to_bytes(&hr.snapshot_header)
n := os.write_at(snapshot, header_bytes, offset) or_return
}
offset += size_of(Snapshot_Header)
for key, &region in hr.regions {
header := hr.region_headers[key]
// Write region header
if hr.write_full_snapshot {
header_bytes := mem.ptr_to_bytes(&header)
os.write_at(snapshot, header_bytes, offset) or_return
}
clear(&hr.loaded_libs)
offset += size_of(Region_Header)
new_api.init()
// Write region name
if hr.write_full_snapshot {
name := key[:header.name_size] // get name from first part of the hash
name_bytes := transmute([]byte)name
os.write_at(snapshot, name_bytes, offset) or_return
}
offset += cast(i64)header.name_size
// Write region data (if bytes differ)
data_bytes := mem.byte_slice(region.ptr_location, header.data_size)
digest := hash.hash_bytes(
hash.Algorithm.SHA256,
data_bytes,
hr.allocator,
)
if hr.write_full_snapshot || mem.compare(digest, region.digest) != 0 {
delete(region.digest, hr.allocator)
region.digest = digest
os.write_at(snapshot, data_bytes, offset) or_return
} else {
delete(digest, hr.allocator)
}
offset += cast(i64)header.data_size
}
append(&hr.loaded_libs, new_api)
hr.write_full_snapshot = false
return active_lib(hr), nil
return nil
}
force_reload :: proc() {
should_close = true
}
force_restart :: proc() -> os.Error {
should_close = true
os.remove(hr.snapshot_path) or_return
return nil
}
+17
View File
@@ -0,0 +1,17 @@
package base
// -----------------------------------------
// Types
Vec2 :: [2]f32
Vec3 :: [3]f32
Vec4 :: [4]f32
Mat3 :: matrix[3, 3]f32
Mat4 :: matrix[4, 4]f32
Vertex :: struct #align (4) {
pos: Vec3,
col: Vec3,
uv: Vec2, // texture coordinate
}
+18
View File
@@ -0,0 +1,18 @@
package core
import "sindri:base"
// -----------------------------------------
// Types
Game_API :: struct {
settings: proc() -> base.Settings,
init_once: proc(),
init: proc(),
update: proc(dt: f32),
render: proc(),
destroy: proc(),
should_close: proc() -> bool,
force_reload: proc() -> bool,
force_restart: proc() -> bool,
}
+80
View File
@@ -0,0 +1,80 @@
package core
import "base:runtime"
import "core:fmt"
import "sindri:base"
// -----------------------------------------
// Types
Model :: struct {
vertices: []base.Vertex,
indices: []u16,
// Some file formats embed their texture
// texture: ^Texture2D,
}
Texture :: struct {
width: u32,
height: u32,
id: u32,
internal_format: u32,
data_format: u32,
data_type: u32,
}
Assets :: struct {
allocator: runtime.Allocator,
model_list: map[string]Model,
texture_list: map[string]Texture,
}
// -----------------------------------------
// Constructor/destructor
assets_init :: proc(allocator := context.allocator) -> Assets {
return {
allocator = allocator,
model_list = make(map[string]Model, allocator),
texture_list = make(map[string]Texture, allocator),
}
}
assets_destroy :: proc(a: ^Assets) {
for _, &model in a.model_list {
delete(model.vertices, a.allocator)
delete(model.indices, a.allocator)
}
for _, &texture in a.texture_list {
}
delete(a.model_list)
delete(a.texture_list)
}
// -----------------------------------------
// Public functions
load_model :: proc(a: ^Assets, path: string) -> ^Model {
if path in a.model_list do return &a.model_list[path]
vertices, indices, ok := load_obj_model(path, a.allocator)
fmt.assertf(ok, "[assets] failed to read model: %s", path)
a.model_list[path] = {
vertices = vertices,
indices = indices,
}
return &a.model_list[path]
}
// Batching:
// max_vertices 60k
// max_elements 60k
// max_texture_slots 32, query gpu or can wgpu do a larger buffer?
// Scene load: loop through all models, add all model sizes together,
// then create a vertex_buffer using the max size, storing the index somewhere.
+149
View File
@@ -0,0 +1,149 @@
package core
import "core:fmt"
import "core:math/linalg"
import "core:os"
import "core:strconv"
import "core:strings"
import "sindri:base"
// Minimal OBJ loader: reads v, vt, vn and triangulates convex polygon faces.
// Produces one GPU vertex per face corner, then centers and normalizes the
// model so it fits roughly inside a [-1, 1] cube at the origin
load_obj_model :: proc(
path: string,
allocator := context.allocator,
) -> (
vertices: []base.Vertex,
indices: []u16,
ok: bool,
) {
data, err := os.read_entire_file_from_path(path, allocator)
if err != os.ERROR_NONE {
fmt.eprintfln("[obj] failed to read model: %s", path)
return nil, nil, false
}
defer delete(data)
positions: [dynamic]base.Vec3
uvs: [dynamic]base.Vec2
normals: [dynamic]base.Vec3
temp_vertices: [dynamic]base.Vertex
temp_indices: [dynamic]u16
defer {
delete(positions)
delete(uvs)
delete(normals)
delete(temp_vertices)
delete(temp_indices)
}
text := string(data)
for line in strings.split_lines_iterator(&text) {
trimmed := strings.trim_space(line)
if len(trimmed) == 0 || trimmed[0] == '#' do continue
fields := strings.fields(trimmed)
defer delete(fields)
if len(fields) == 0 do continue
switch fields[0] {
case "v":
if len(fields) < 4 do continue
x, _ := strconv.parse_f32(fields[1])
y, _ := strconv.parse_f32(fields[2])
z, _ := strconv.parse_f32(fields[3])
append(&positions, base.Vec3{x, y, z})
case "vt":
if len(fields) < 3 do continue
u, _ := strconv.parse_f32(fields[1])
v, _ := strconv.parse_f32(fields[2])
append(&uvs, base.Vec2{u, v})
case "vn":
if len(fields) < 4 do continue
x, _ := strconv.parse_f32(fields[1])
y, _ := strconv.parse_f32(fields[2])
z, _ := strconv.parse_f32(fields[3])
append(&normals, base.Vec3{x, y, z})
case "f":
face_indices: [dynamic]u16
defer delete(face_indices)
for i := 1; i < len(fields); i += 1 {
token := fields[i]
slash := strings.index_byte(token, '/')
vi_str := token
vti_str := ""
if slash != -1 {
vi_str = token[:slash]
slash2 := strings.index_byte(token[slash + 1:], '/')
if slash2 != -1 {
vti_str = token[slash + 1:slash + 1 + slash2]
} else {
vti_str = token[slash + 1:]
}
}
vi, _ := strconv.parse_int(vi_str)
vi -= 1 // OBJ indices are 1-based
if vi < 0 || vi >= len(positions) do continue
uv := base.Vec2{0, 0}
if len(vti_str) > 0 {
vti, _ := strconv.parse_int(vti_str)
vti -= 1
if vti >= 0 && vti < len(uvs) {
uv = uvs[vti]
}
} else if vi < len(uvs) {
uv = uvs[vi]
}
append(
&temp_vertices,
base.Vertex {
pos = positions[vi],
col = {1, 1, 1},
uv = uv,
},
)
append(&face_indices, cast(u16)(len(temp_vertices) - 1))
}
// Triangulate convex polygon by fanning from the first vertex
for i := 2; i < len(face_indices); i += 1 {
append(&temp_indices, face_indices[0])
append(&temp_indices, face_indices[i - 1])
append(&temp_indices, face_indices[i])
}
}
}
if len(temp_vertices) == 0 {
return nil, nil, false
}
// Center and normalize: fit the model inside a roughly [-1, 1] cube
min_p := temp_vertices[0].pos
max_p := temp_vertices[0].pos
for v in temp_vertices {
min_p = linalg.min(min_p, v.pos)
max_p = linalg.max(max_p, v.pos)
}
center := (min_p + max_p) * 0.5
size := max_p - min_p
max_size := max(size.x, size.y, size.z)
scale := 1.8 / max_size
for &v in temp_vertices {
v.pos = (v.pos - center) * scale
}
vertices = make([]base.Vertex, len(temp_vertices), allocator)
indices = make([]u16, len(temp_indices), allocator)
copy(vertices, temp_vertices[:])
copy(indices, temp_indices[:])
return vertices, indices, true
}
+66 -16
View File
@@ -3,7 +3,9 @@ package sindri
import "core:fmt"
import "core:time"
import "game:game"
import "sindri:base"
import "sindri:core"
import "sindri:platform"
VERSION :: #config(VERSION, "dev")
@@ -14,8 +16,13 @@ main :: proc() {
fmt.println("hello world!", VERSION)
// Hot reload development functionality
hr, err := base.hot_reload_init()
defer base.hot_reload_destroy(&hr)
base.hot_reload_init(VERSION)
defer base.hot_reload_destroy()
// Load game API & settings
api := game.api()
defer api.destroy()
settings := api.settings()
// Event system
base.event_bus_init()
@@ -23,8 +30,9 @@ main :: proc() {
base.event_subscribe(base.Key_Press_Event, test)
api := base.active_lib(&hr)
settings := api.settings()
// Listen for OS signals (SIGINT, SIGTERM)
platform.signals_init()
defer platform.signals_destroy()
// Initialize Window
platform.os_init(settings)
@@ -34,34 +42,76 @@ main :: proc() {
// Initialize GPU resources
platform.instance_init(settings)
defer platform.instance_destroy()
load_test_model()
api.init_once()
base.read_snapshot()
api.init()
gt: f64 = 0
dt: f32
dt: f64
gt: f64
st: f64
for !api.should_close() &&
!platform.os_should_close() &&
!base.should_close {
for !platform.os_should_close() && !base.should_close(&hr) {
start := time.tick_now()
platform.os_poll_events()
base.drain_event_queue()
api.update(dt)
api.update(cast(f32)dt)
base.write_snapshot()
platform.frame(dt)
platform.frame()
api.render()
dt = f32(time.duration_seconds(time.tick_since(start)))
gt += f64(dt)
dt = time.duration_seconds(time.tick_since(start))
gt += dt
st += dt
// Hot reload
api, err = base.reload_game_lib(&hr)
if err != nil do break
if st > 5 {
st -= 5
base.set_write_full_snapshot()
}
if api.force_reload() do base.force_reload()
if api.force_restart() do base.force_restart()
}
api.destroy()
}
test :: proc(e: ^base.Key_Press_Event, _: rawptr) -> bool {
fmt.println("main: @@@@@@@@@@@", e)
return true
}
// Load model from disk and upload it
load_test_model :: proc() {
assets := core.assets_init()
defer core.assets_destroy(&assets)
cube := core.load_model(&assets, "assets/cube.obj")
suzanne := core.load_model(&assets, "assets/suzanne.obj")
platform.create_material("dummy")
platform.create_renderable(
"assets/cube.obj",
cube.vertices,
cube.indices,
0,
)
platform.create_renderable(
"assets/suzanne.obj",
suzanne.vertices,
suzanne.indices,
3,
)
platform.create_renderable(
"assets/suzanne.obj",
suzanne.vertices,
suzanne.indices,
-3,
)
}
+4 -4
View File
@@ -1,5 +1,6 @@
package platform
import "base:intrinsics"
import "core:fmt"
import "core:strings"
import "vendor:glfw"
@@ -20,9 +21,7 @@ OS :: struct {
// Constructor / destructor
os_init :: proc(settings: base.Settings) {
if !glfw.Init() {
panic("[glfw] init failure")
}
assert(cast(bool)glfw.Init(), "[glfw] init failure")
// Set window properties
glfw.WindowHint(glfw.CLIENT_API, glfw.NO_API) // do not create OpenGL context
@@ -113,7 +112,8 @@ os_set_callback_proc :: proc(window: rawptr) {
}
os_should_close :: proc() -> bool {
return bool(glfw.WindowShouldClose(state.os.window))
window_close := bool(glfw.WindowShouldClose(state.os.window))
return window_close || intrinsics.atomic_load(&quit_signal_received)
}
os_set_should_close :: proc(val: bool) {
+48
View File
@@ -0,0 +1,48 @@
package platform
import "base:intrinsics"
import "core:c/libc"
import "core:fmt"
// -----------------------------------------
// Variables
// Read with atomic_load, safe across threads / interrupt contexts
quit_signal_received: bool
// -----------------------------------------
// Public functions
// Register signal handlers
signals_init :: proc() {
// SIGINT = Interrupt, typically Ctrl+C
if libc.signal(libc.SIGINT, signal_handler) ==
cast(proc "c" (_: i32))libc.SIG_ERR {
fmt.eprintln("error: failed to register SIGINT handler")
}
// SIGTERM = Normal termination
if libc.signal(libc.SIGTERM, signal_handler) ==
cast(proc "c" (_: i32))libc.SIG_ERR {
fmt.eprintln("error: failed to register SIGTERM handler")
}
}
// Restore default signal handlers
signals_destroy :: proc() {
libc.signal(libc.SIGINT, cast(proc "c" (_: i32))libc.SIG_DFL)
libc.signal(libc.SIGTERM, cast(proc "c" (_: i32))libc.SIG_DFL)
}
@(private = "file")
signal_handler :: proc "c" (sig: i32) {
switch sig {
case libc.SIGINT:
fallthrough
case libc.SIGTERM:
intrinsics.atomic_store(&quit_signal_received, true)
case:
context = state.ctx
panic("[signal] unhandled signal type")
}
}
+697 -32
View File
@@ -2,40 +2,103 @@ package platform
import "base:runtime"
import "core:fmt"
import "core:math"
import "core:math/linalg"
import "core:os"
import "core:slice"
import "wgpu:wgpu"
import "sindri:base"
// -----------------------------------------
// Types
// Updated once per frame
Camera_Uniform :: struct {
mvp: base.Mat4,
}
// Updated once per material
Material_Uniform :: struct {
dummy: f32,
}
// Updated once per mesh
Object_Uniform :: struct {
transform: base.Mat4,
}
Material :: struct {
bind_group: wgpu.BindGroup,
uniform_buffer: wgpu.Buffer,
}
Object :: struct {
name: string,
bind_group: wgpu.BindGroup,
uniform_buffer: wgpu.Buffer,
}
Mesh :: struct {
vertex_buffer: wgpu.Buffer,
index_buffer: wgpu.Buffer,
index_count: u32,
}
// -----------------------------------------
// Variables
// Instance -> Surface -> Adapter -> Device -> Queue
state: struct {
ctx: runtime.Context,
os: OS,
ctx: runtime.Context,
os: OS,
// ----------------------------------------
instance: wgpu.Instance, // entry point: creates Adapter, Device and Surface
surface: wgpu.Surface, // handle to a presentable surface (e.g. a window)
adapter: wgpu.Adapter, // handle to physical GPU
device: wgpu.Device, // open connection to a GPU
queue: wgpu.Queue, // handle to command queue on a Device
config: wgpu.SurfaceConfiguration,
module: wgpu.ShaderModule,
pipeline_layout: wgpu.PipelineLayout,
pipeline: wgpu.RenderPipeline,
instance: wgpu.Instance, // entry point: creates Adapter, Device and Surface
surface: wgpu.Surface, // handle to a presentable surface (e.g. a window)
adapter: wgpu.Adapter, // handle to physical GPU
device: wgpu.Device, // open connection to a GPU
queue: wgpu.Queue, // handle to command queue on a Device
config: wgpu.SurfaceConfiguration,
module: wgpu.ShaderModule,
pipeline_layout: wgpu.PipelineLayout,
pipeline: wgpu.RenderPipeline,
// ----------------------------------------
capabilities: wgpu.SurfaceCapabilities,
vsync: bool,
capabilities: wgpu.SurfaceCapabilities,
vsync: bool,
// ----------------------------------------
depth_texture: wgpu.Texture,
depth_texture_view: wgpu.TextureView,
// ----------------------------------------
camera_uniform_buffer: wgpu.Buffer,
camera_bind_group_layout: wgpu.BindGroupLayout,
camera_bind_group: wgpu.BindGroup,
// ----------------------------------------
material_bind_group_layout: wgpu.BindGroupLayout,
materials: map[string]Material,
// ----------------------------------------
objects_bind_group_layout: wgpu.BindGroupLayout,
objects: [dynamic]Object,
meshes: map[string]Mesh,
// ----------------------------------------
diffuse_texture: wgpu.Texture,
diffuse_texture_view: wgpu.TextureView,
diffuse_sampler: wgpu.Sampler,
}
// -----------------------------------------
// Constructor/destructor
instance_init :: proc(settings: base.Settings) {
instance_init :: proc(
settings: base.Settings,
allocator := context.allocator,
) {
state.ctx = context
state.materials = make(map[string]Material, allocator)
state.objects = make([dynamic]Object, allocator)
state.meshes = make(map[string]Mesh, allocator)
wgpu.SetLogCallback(log_callback, nil)
wgpu.SetLogLevel(.Warn)
@@ -64,6 +127,42 @@ instance_init :: proc(settings: base.Settings) {
}
instance_destroy :: proc() {
wgpu.TextureViewRelease(state.depth_texture_view)
wgpu.TextureRelease(state.depth_texture)
wgpu.SamplerRelease(state.diffuse_sampler)
wgpu.TextureViewRelease(state.diffuse_texture_view)
wgpu.TextureRelease(state.diffuse_texture)
wgpu.BufferRelease(state.camera_uniform_buffer)
wgpu.BindGroupRelease(state.camera_bind_group)
wgpu.BindGroupLayoutRelease(state.camera_bind_group_layout)
if state.materials != nil {
for _, m in state.materials {
wgpu.BufferRelease(m.uniform_buffer)
wgpu.BindGroupRelease(m.bind_group)
}
delete(state.materials)
}
wgpu.BindGroupLayoutRelease(state.material_bind_group_layout)
if state.meshes != nil {
for _, m in state.meshes {
wgpu.BufferRelease(m.vertex_buffer)
wgpu.BufferRelease(m.index_buffer)
}
delete(state.meshes)
}
if state.objects != nil {
for r in state.objects {
wgpu.BufferRelease(r.uniform_buffer)
wgpu.BindGroupRelease(r.bind_group)
}
delete(state.objects)
}
wgpu.BindGroupLayoutRelease(state.objects_bind_group_layout)
wgpu.RenderPipelineRelease(state.pipeline)
wgpu.PipelineLayoutRelease(state.pipeline_layout)
wgpu.ShaderModuleRelease(state.module)
@@ -94,9 +193,18 @@ resize :: proc "c" () {
state.config.width, state.config.height = os_get_framebuffer_size()
wgpu.SurfaceConfigure(state.surface, &state.config)
create_depth_texture()
// The projection depends on aspect, so rebuild and re-upload the matrix
upload_camera(
state.queue,
state.camera_uniform_buffer,
framebuffer_aspect(),
)
}
frame :: proc "c" (dt: f32) {
frame :: proc "c" () {
context = state.ctx
surface_texture := wgpu.SurfaceGetCurrentTexture(state.surface)
@@ -137,20 +245,74 @@ frame :: proc "c" (dt: f32) {
loadOp = .Clear,
storeOp = .Store,
depthSlice = wgpu.DEPTH_SLICE_UNDEFINED,
clearValue = {0, 1, 0, 1},
clearValue = {55 / 255.0, 73 / 255.0, 73 / 255.0, 1},
},
depthStencilAttachment = &wgpu.RenderPassDepthStencilAttachment {
view = state.depth_texture_view,
depthLoadOp = .Clear,
depthStoreOp = .Store,
depthClearValue = 1.0,
},
},
)
wgpu.RenderPassEncoderSetPipeline(render_pass_encoder, state.pipeline)
wgpu.RenderPassEncoderDraw(
// Uniform group 0, camera MVP matrix
wgpu.RenderPassEncoderSetBindGroup(
render_pass_encoder,
vertexCount = 3,
instanceCount = 1,
firstVertex = 0,
firstInstance = 0,
0,
state.camera_bind_group,
dynamicOffsets = nil, // reuse bind group af different byte offsets, per binding
)
// Uniform group 1, materials
m := state.materials["dummy"]
wgpu.RenderPassEncoderSetBindGroup(
render_pass_encoder,
1,
m.bind_group,
dynamicOffsets = nil, // reuse bind group af different byte offsets, per binding
)
// Render all objects
for o in state.objects {
m := state.meshes[o.name]
// Uniform group 2, object
wgpu.RenderPassEncoderSetBindGroup(
render_pass_encoder,
2,
o.bind_group,
dynamicOffsets = nil, // reuse bind group af different byte offsets, per binding
)
wgpu.RenderPassEncoderSetVertexBuffer(
render_pass_encoder,
slot = 0,
buffer = m.vertex_buffer,
offset = 0, // offset into buffer to start from
size = wgpu.WHOLE_SIZE, // x bytes of the buffer to use
)
wgpu.RenderPassEncoderSetIndexBuffer(
render_pass_encoder,
buffer = m.index_buffer,
format = .Uint16, // type of each element in the buffer
offset = 0, // offset into buffer to start from
size = wgpu.WHOLE_SIZE, // x bytes of the buffer to use
)
wgpu.RenderPassEncoderDrawIndexed(
render_pass_encoder,
m.index_count,
instanceCount = 1, // draw x instance(s) of the mesh
firstIndex = 0, // offset into buffer to start from, useful for drawing sub-mesh
baseVertex = 0, // value added to index before vertex fetch, useful for multi-mesh vertex buffers
firstInstance = 0, // number to start instance index from
)
}
wgpu.RenderPassEncoderEnd(render_pass_encoder)
wgpu.RenderPassEncoderRelease(render_pass_encoder)
@@ -161,6 +323,152 @@ frame :: proc "c" (dt: f32) {
wgpu.SurfacePresent(state.surface)
}
create_material :: proc(name: string) {
if name in state.materials do return
uniform_buffer := wgpu.DeviceCreateBuffer(
state.device,
&{
label = "material uniform",
usage = {.Uniform, .CopyDst}, // shader reads, queue writes
size = cast(u64)size_of(Material_Uniform),
},
)
uniform := Material_Uniform{}
// bufferOffset = offset into buffer to start from
wgpu.QueueWriteBuffer(
state.queue,
uniform_buffer,
0,
&uniform,
size_of(Material_Uniform),
)
entries := [?]wgpu.BindGroupEntry {
{
binding = 0, // slot 0
buffer = uniform_buffer,
offset = 0, // offset into buffer to start from
size = cast(u64)size_of(Material_Uniform),
},
}
bind_group := wgpu.DeviceCreateBindGroup(
state.device,
&{
label = "material bind group",
layout = state.material_bind_group_layout,
entryCount = len(entries),
entries = raw_data(entries[:]),
},
)
state.materials[name] = {
bind_group = bind_group,
uniform_buffer = uniform_buffer,
}
}
create_renderable :: proc(
name: string,
vertices: []base.Vertex,
indices: []u16,
x_offset: f32,
) {
uniform_buffer := wgpu.DeviceCreateBuffer(
state.device,
&{
label = "object uniform",
usage = {.Uniform, .CopyDst}, // shader reads, queue writes
size = cast(u64)size_of(Object_Uniform),
},
)
uniform := Object_Uniform {
// transform = linalg.identity(base.Mat4)
transform = linalg.matrix4_translate(base.Vec3{x_offset, 0, 0}),
}
// bufferOffset = offset into buffer to start from
wgpu.QueueWriteBuffer(
state.queue,
uniform_buffer,
0,
&uniform,
size_of(Object_Uniform),
)
entries := [?]wgpu.BindGroupEntry {
{
binding = 0, // slot 0
buffer = uniform_buffer,
offset = 0, // offset into buffer to start from
size = cast(u64)size_of(Object_Uniform),
},
}
bind_group := wgpu.DeviceCreateBindGroup(
state.device,
&{
label = "object bind group",
layout = state.objects_bind_group_layout,
entryCount = len(entries),
entries = raw_data(entries[:]),
},
)
append(
&state.objects,
Object {
name = name,
bind_group = bind_group,
uniform_buffer = uniform_buffer,
},
)
if name in state.meshes do return
vertex_buffer := wgpu.DeviceCreateBuffer(
state.device,
&{
label = "vertex buffer",
usage = {.Vertex, .CopyDst}, // shader reads, queue writes
size = cast(u64)(len(vertices) * size_of(base.Vertex)),
},
)
wgpu.QueueWriteBuffer(
state.queue,
vertex_buffer,
0,
raw_data(vertices),
cast(uint)(len(vertices) * size_of(base.Vertex)),
)
index_buffer := wgpu.DeviceCreateBuffer(
state.device,
&{
label = "index buffer",
usage = {.Index, .CopyDst}, // shader reads, queue writes
size = cast(u64)(len(indices) * size_of(u16)),
},
)
wgpu.QueueWriteBuffer(
state.queue,
index_buffer,
0,
raw_data(indices),
cast(uint)(len(indices) * size_of(u16)),
)
state.meshes[name] = {
vertex_buffer = vertex_buffer,
index_buffer = index_buffer,
index_count = cast(u32)len(indices),
}
}
// -----------------------------------------
// Private functions
@@ -208,18 +516,70 @@ on_device :: proc "c" (
}
wgpu.SurfaceConfigure(state.surface, &state.config)
shader :: `
// ----------------------------------------
// Depth buffer
create_depth_texture()
// ----------------------------------------
// Compile shader
uniforms_struct := to_wgsl_struct(Camera_Uniform, "Camera")
defer delete(uniforms_struct)
vertex_input_struct := to_wgsl_struct(
base.Vertex,
"VertexInput",
locations = true,
)
defer delete(vertex_input_struct)
material_struct := to_wgsl_struct(Material_Uniform, "Material")
defer delete(material_struct)
renderable_struct := to_wgsl_struct(Object_Uniform, "Renderable")
defer delete(renderable_struct)
shader := fmt.tprintf(
`
%s
%s
%s
@group(0) @binding(0) var<uniform> c: Camera;
@group(0) @binding(1) var t_diffuse: texture_2d<f32>;
@group(0) @binding(2) var s_diffuse: sampler;
@group(1) @binding(0) var<uniform> m: Material;
@group(2) @binding(0) var<uniform> r: Renderable;
%s
struct VertexOutput {{
@builtin(position) position: vec4<f32>,
@location(0) col: vec3<f32>,
@location(1) uv: vec2<f32>,
}};
@vertex
fn vs_main(@builtin(vertex_index) in_vertex_index: u32) -> @builtin(position) vec4<f32> {
let x = f32(i32(in_vertex_index) - 1);
let y = f32(i32(in_vertex_index & 1u) * 2 - 1);
return vec4<f32>(x, y, 0.0, 1.0);
}
fn vs_main(in: VertexInput) -> VertexOutput {{
var out: VertexOutput;
out.position = c.mvp * r.transform * vec4<f32>(in.pos, 1.0);
out.col = in.col;
out.uv = in.uv;
return out;
}}
@fragment
fn fs_main() -> @location(0) vec4<f32> {
return vec4<f32>(1.0, 0.0, 0.0, 1.0);
}`
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {{
return textureSample(t_diffuse, s_diffuse, in.uv) * vec4<f32>(in.col, 1.0);
}}
`,
uniforms_struct,
material_struct,
renderable_struct,
vertex_input_struct,
)
state.module = wgpu.DeviceCreateShaderModule(
state.device,
@@ -231,12 +591,140 @@ on_device :: proc "c" (
},
)
state.pipeline_layout = wgpu.DeviceCreatePipelineLayout(state.device, &{})
// ----------------------------------------
// Camera uniform: buffer, then the baked layout it will be read through
state.camera_uniform_buffer = wgpu.DeviceCreateBuffer(
state.device,
&{
label = "camera uniform",
usage = {.Uniform, .CopyDst}, // shader reads, queue writes
size = cast(u64)size_of(Camera_Uniform),
},
)
camera_layout_entries := [?]wgpu.BindGroupLayoutEntry {
{
binding = 0, // slot 0
visibility = {.Vertex},
buffer = {
type = .Uniform,
minBindingSize = cast(u64)size_of(Camera_Uniform),
},
},
{
binding = 1, // slot 1
visibility = {.Fragment},
texture = {
sampleType = .Float,
viewDimension = ._2D,
multisampled = false,
},
},
{
binding = 2, // slot 2
visibility = {.Fragment},
sampler = {type = .Filtering},
},
}
state.camera_bind_group_layout = wgpu.DeviceCreateBindGroupLayout(
state.device,
&{
label = "camera layout",
entryCount = len(camera_layout_entries),
entries = raw_data(camera_layout_entries[:]),
},
)
// ----------------------------------------
// Material baked layout
material_layout_entries := [?]wgpu.BindGroupLayoutEntry {
{
binding = 0, // slot 0
visibility = {.Fragment},
buffer = {
type = .Uniform,
minBindingSize = cast(u64)size_of(Material_Uniform),
},
},
}
state.material_bind_group_layout = wgpu.DeviceCreateBindGroupLayout(
state.device,
&{
label = "material layout",
entryCount = len(material_layout_entries),
entries = raw_data(material_layout_entries[:]),
},
)
// ----------------------------------------
// Renderable baked layout
renderable_layout_entries := [?]wgpu.BindGroupLayoutEntry {
{
binding = 0, // slot 0
visibility = {.Vertex},
buffer = {
type = .Uniform,
minBindingSize = cast(u64)size_of(Object_Uniform),
},
},
}
state.objects_bind_group_layout = wgpu.DeviceCreateBindGroupLayout(
state.device,
&{
label = "renderable layout",
entryCount = len(renderable_layout_entries),
entries = raw_data(renderable_layout_entries[:]),
},
)
// ----------------------------------------
// Diffuse texture + sampler
state.diffuse_texture, state.diffuse_texture_view, state.diffuse_sampler =
create_diffuse_texture(state.device, state.queue)
// ----------------------------------------
// Buffer shape
vertex_attributes := to_vertex_attributes(base.Vertex)
vertex_layout := wgpu.VertexBufferLayout {
stepMode = .Vertex,
arrayStride = size_of(base.Vertex),
attributeCount = len(vertex_attributes),
attributes = raw_data(vertex_attributes),
}
// ----------------------------------------
// Render pipeline
group_layouts := [?]wgpu.BindGroupLayout {
state.camera_bind_group_layout,
state.material_bind_group_layout,
state.objects_bind_group_layout,
}
state.pipeline_layout = wgpu.DeviceCreatePipelineLayout(
state.device,
&{
bindGroupLayoutCount = len(group_layouts),
bindGroupLayouts = raw_data(group_layouts[:]),
},
)
state.pipeline = wgpu.DeviceCreateRenderPipeline(
state.device,
&{
label = "pipeline",
layout = state.pipeline_layout,
vertex = {module = state.module, entryPoint = "vs_main"},
vertex = {
module = state.module,
entryPoint = "vs_main",
bufferCount = 1,
buffers = &vertex_layout,
},
fragment = &{
module = state.module,
entryPoint = "fs_main",
@@ -248,8 +736,43 @@ on_device :: proc "c" (
},
primitive = {topology = .TriangleList},
multisample = {count = 1, mask = 0xFFFFFFFF},
depthStencil = &wgpu.DepthStencilState {
format = .Depth24Plus,
depthWriteEnabled = .True,
depthCompare = .Less,
},
},
)
// ----------------------------------------
// Camera bind group
bind_group_entries := [?]wgpu.BindGroupEntry {
{
binding = 0, // slot 0
buffer = state.camera_uniform_buffer,
offset = 0, // offset into buffer to start from
size = cast(u64)size_of(Camera_Uniform),
},
{binding = 1, textureView = state.diffuse_texture_view},
{binding = 2, sampler = state.diffuse_sampler},
}
state.camera_bind_group = wgpu.DeviceCreateBindGroup(
state.device,
&{
label = "camera bind group",
layout = state.camera_bind_group_layout,
entryCount = len(bind_group_entries),
entries = raw_data(bind_group_entries[:]),
},
)
// Initial upload of the matrix
upload_camera(
state.queue,
state.camera_uniform_buffer,
framebuffer_aspect(),
)
}
@(private = "file")
@@ -290,3 +813,145 @@ pick_present_mode :: proc(vsync: bool) -> wgpu.PresentMode {
return .Fifo // Vsync On
}
// Depth buffer: same size as the surface, should get recreated on resize
@(private = "file")
create_depth_texture :: proc() {
if state.depth_texture != nil {
wgpu.TextureViewRelease(state.depth_texture_view)
wgpu.TextureRelease(state.depth_texture)
}
state.depth_texture = wgpu.DeviceCreateTexture(
state.device,
&{
label = "depth texture",
usage = {.RenderAttachment},
dimension = ._2D,
size = {
width = state.config.width,
height = state.config.height,
depthOrArrayLayers = 1,
},
format = .Depth24Plus,
mipLevelCount = 1,
sampleCount = 1,
},
)
state.depth_texture_view = wgpu.TextureCreateView(state.depth_texture, nil)
}
@(private = "file")
framebuffer_aspect :: proc() -> f32 {
width, height := os_get_framebuffer_size()
if height == 0 {
return 1
}
return cast(f32)width / cast(f32)height
}
// Perspective projection, WebGPU flavour
@(private = "file")
camera_perspective :: proc(fovy_degrees, aspect, near, far: f32) -> base.Mat4 {
gl := linalg.matrix4_perspective(
fovy_degrees * math.PI / 180.0,
aspect,
near,
far,
)
// GL's clip space is [-1, 1] and WebGPU's clip space is [0, 1],
// so fold z with a remap: z' = 1/2 z + 1/2 w
remap := linalg.identity(base.Mat4)
remap[2, 2] = 0.5
remap[2, 3] = 0.5
return remap * gl
}
@(private = "file")
camera_mvp :: proc(aspect: f32) -> base.Mat4 {
projection := camera_perspective(60, aspect, 0.1, 100)
view := linalg.matrix4_look_at_f32({0, 2, 5}, {0, 0, 0}, {0, 1, 0})
// model = identity: the loader already centered the model at the origin
return projection * view
}
@(private = "file")
upload_camera :: proc(queue: wgpu.Queue, buffer: wgpu.Buffer, aspect: f32) {
uniforms := Camera_Uniform {
mvp = camera_mvp(aspect),
}
// bufferOffset = offset into buffer to start from
wgpu.QueueWriteBuffer(queue, buffer, 0, &uniforms, size_of(Camera_Uniform))
}
// Test 256x256 checkerboard texture, uploaded once
@(private = "file")
create_diffuse_texture :: proc(
device: wgpu.Device,
queue: wgpu.Queue,
) -> (
wgpu.Texture,
wgpu.TextureView,
wgpu.Sampler,
) {
width, height: u32 = 256, 256
pixels := make([]u8, width * height * 4)
defer delete(pixels)
for y in 0 ..< height {
for x in 0 ..< width {
checker := ((x / 32) + (y / 32)) % 2 == 0
idx := (y * width + x) * 4
if checker {
pixels[idx + 0] = 240
pixels[idx + 1] = 240
pixels[idx + 2] = 240
pixels[idx + 3] = 255
} else {
pixels[idx + 0] = 64
pixels[idx + 1] = 64
pixels[idx + 2] = 64
pixels[idx + 3] = 255
}
}
}
texture := wgpu.DeviceCreateTexture(
device,
&{
label = "diffuse texture",
usage = {.TextureBinding, .CopyDst}, // shader samples, queue writes
dimension = ._2D,
size = {width = width, height = height, depthOrArrayLayers = 1},
format = .RGBA8Unorm,
mipLevelCount = 1,
sampleCount = 1,
},
)
// Upload texture to GPU
wgpu.QueueWriteTexture(
queue,
&{texture = texture, mipLevel = 0, origin = {0, 0, 0}, aspect = .All}, // destination
raw_data(pixels),
len(pixels),
&{offset = 0, bytesPerRow = width * 4, rowsPerImage = height}, // data layout
&{width = width, height = height, depthOrArrayLayers = 1}, // 3D extent
)
view := wgpu.TextureCreateView(texture, nil)
sampler := wgpu.DeviceCreateSampler(
device,
&{
addressModeU = .Repeat,
addressModeV = .Repeat,
magFilter = .Nearest,
minFilter = .Nearest,
mipmapFilter = .Nearest,
maxAnisotropy = 1,
},
)
return texture, view, sampler
}
+276
View File
@@ -0,0 +1,276 @@
package platform
import "core:fmt"
import "core:reflect"
import "core:strings"
import "wgpu:wgpu"
// -----------------------------------------
// Types
// -----------------------------------------
// Public functions
// Generate a WGSL struct declaration from an Odin struct type.
// The caller owns the returned string (allocated on context.allocator).
//
// If locations is true, each member is prefixed with @location(n). This is
// required for vertex input structs; uniform structs should use false.
to_wgsl_struct :: proc(
$T: typeid,
name: string,
locations := false,
) -> string {
ti := base_type_info(type_info_of(T))
s, ok := ti.variant.(reflect.Type_Info_Struct)
if !ok do fmt.panicf("[wgpu_reflect] %s is not a struct", typeid_of(T))
b: strings.Builder
strings.write_string(&b, "struct ")
strings.write_string(&b, name)
strings.write_string(&b, " {\n")
names := s.names[:s.field_count]
types := s.types[:s.field_count]
for i in 0 ..< s.field_count {
wgsl_type, type_ok := to_wgsl_type(types[i])
if !type_ok {
fmt.panicf("[wgpu_reflect] cannot map field %s to WGSL", names[i])
}
if locations {
fmt.sbprintf(
&b,
"\t@location(%d) %s: %s,\n",
i,
names[i],
wgsl_type,
)
} else {
fmt.sbprintf(&b, "\t%s: %s,\n", names[i], wgsl_type)
}
}
strings.write_string(&b, "};")
return strings.to_string(b)
}
// Generate a []wgpu.VertexAttribute array that matches the fields of struct T.
// The caller owns the returned slice (allocated on context.allocator)
to_vertex_attributes :: proc($T: typeid) -> []wgpu.VertexAttribute {
ti := base_type_info(type_info_of(T))
s, ok := ti.variant.(reflect.Type_Info_Struct)
if !ok do fmt.panicf("[wgpu_reflect] %s is not a struct", typeid_of(T))
attrs := make([]wgpu.VertexAttribute, s.field_count)
names := s.names[:s.field_count]
types := s.types[:s.field_count]
offsets := s.offsets[:s.field_count]
for i in 0 ..< s.field_count {
format, format_ok := to_vertex_format(types[i])
if !format_ok {
fmt.panicf(
"[wgpu_reflect] cannot map field %s to a vertex format",
names[i],
)
}
attrs[i] = wgpu.VertexAttribute {
format = format,
offset = cast(u64)offsets[i],
shaderLocation = u32(i),
}
}
return attrs
}
// -----------------------------------------
// Private functions
// Strips named-type wrappers so we can inspect the underlying type
@(private = "file")
base_type_info :: proc(type_info: ^reflect.Type_Info) -> ^reflect.Type_Info {
#partial switch v in type_info.variant {
case reflect.Type_Info_Named:
return base_type_info(v.base)
}
return type_info
}
// Map an Odin type to a WGSL scalar/vector/matrix type name
@(private = "file")
to_wgsl_type :: proc(type_info: ^reflect.Type_Info) -> (string, bool) {
ti := base_type_info(type_info)
#partial switch v in ti.variant {
case reflect.Type_Info_Float:
switch ti.size {
case 4:
return "f32", true
case 8:
return "f64", true
}
case reflect.Type_Info_Integer:
prefix := v.signed ? "i" : "u"
switch ti.size {
case 1:
return fmt.tprintf("%s8", prefix), true
case 2:
return fmt.tprintf("%s16", prefix), true
case 4:
return fmt.tprintf("%s32", prefix), true
case 8:
return fmt.tprintf("%s64", prefix), true
}
case reflect.Type_Info_Array:
elem_type, elem_ok := to_wgsl_type(v.elem)
if !elem_ok do return "", false
switch v.count {
case 2:
return fmt.tprintf("vec2<%s>", elem_type), true
case 3:
return fmt.tprintf("vec3<%s>", elem_type), true
case 4:
return fmt.tprintf("vec4<%s>", elem_type), true
}
case reflect.Type_Info_Matrix:
elem_type, elem_ok := to_wgsl_type(v.elem)
if !elem_ok do return "", false
return fmt.tprintf(
"mat%dx%d<%s>",
v.column_count,
v.row_count,
elem_type,
),
true
case:
fmt.panicf("[wgpu_reflect] unimplemented type translation: %v", ti)
}
return "", false
}
// Map an Odin type to a wgpu.VertexFormat.
// Only handles the scalar/vector numeric formats commonly used as vertex attributes
@(private = "file")
to_vertex_format :: proc(
type_info: ^reflect.Type_Info,
) -> (
wgpu.VertexFormat,
bool,
) {
ti := base_type_info(type_info)
#partial switch v in ti.variant {
case reflect.Type_Info_Float:
if ti.size == 2 do return .Float16, true
else if ti.size == 4 do return .Float32, true
case reflect.Type_Info_Integer:
if v.signed {
switch ti.size {
case 1:
return .Sint8, true
case 2:
return .Sint16, true
case 4:
return .Sint32, true
}
} else {
switch ti.size {
case 1:
return .Uint8, true
case 2:
return .Uint16, true
case 4:
return .Uint32, true
}
}
case reflect.Type_Info_Array:
elem := base_type_info(v.elem)
#partial switch ev in elem.variant {
case reflect.Type_Info_Float:
if elem.size == 2 {
switch v.count {
case 2:
return .Float16x2, true
case 4:
return .Float16x4, true
}
} else if elem.size == 4 {
switch v.count {
case 2:
return .Float32x2, true
case 3:
return .Float32x3, true
case 4:
return .Float32x4, true
}
}
case reflect.Type_Info_Integer:
if ev.signed {
switch elem.size {
case 1:
switch v.count {
case 2:
return .Sint8x2, true
case 4:
return .Sint8x4, true
}
case 2:
switch v.count {
case 2:
return .Sint16x2, true
case 4:
return .Sint16x4, true
}
case 4:
switch v.count {
case 2:
return .Sint32x2, true
case 3:
return .Sint32x3, true
case 4:
return .Sint32x4, true
}
}
} else {
switch elem.size {
case 1:
switch v.count {
case 2:
return .Uint8x2, true
case 4:
return .Uint8x4, true
}
case 2:
switch (v.count) {
case 2:
return .Uint16x2, true
case 4:
return .Uint16x4, true
}
case 4:
switch (v.count) {
case 2:
return .Uint32x2, true
case 3:
return .Uint32x3, true
case 4:
return .Uint32x4, true
}
}
}
}
}
return .Float32, false
}