diff --git a/Makefile b/Makefile index 6f2454a..b91c22f 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ -.PHONY: shaders-vulkan shaders-d3d12 shaders-metal shaders-all bake toad hello_sprite crowd camera_sandbox clips check test help +.PHONY: shaders-vulkan shaders-d3d12 shaders-metal shaders-all bake toad hello_sprite crowd crowd_batch crowd_overdraw clip_thrash camera_sandbox clips check test help .PHONY: flame flame-build flame-record flame-svg flame-report flame-tools -# Flamegraph profiling (needs: pacman -S perf). Example: make flame or make flame FLAME_EXAMPLE=toad +# Flamegraph profiling (needs: pacman -S perf). Example: make flame or make flame FLAME_EXAMPLE=crowd_batch FLAME_EXAMPLE ?= crowd FLAME_BIN := $(FLAME_EXAMPLE)_perf FLAMEGRAPH_DIR ?= tools/FlameGraph @@ -24,6 +24,9 @@ help: @echo " toad Run the toad example (full demo)" @echo " hello_sprite Minimal load + draw" @echo " crowd Many sprites, one Character_Data" + @echo " crowd_batch Multi-texture batching stress (two toad loads)" + @echo " crowd_overdraw Stacked sprites overdraw stress" + @echo " clip_thrash Per-frame idle/walk clip flip stress" @echo " camera_sandbox Pan camera / Space toggles follow" @echo " clips Keys 1/2 switch idle/walk" @echo " flame Build+record+SVG+JPG+text report (FLAME_EXAMPLE=$(FLAME_EXAMPLE))" @@ -54,6 +57,9 @@ check: odin check examples/toad -collection:pkg=. odin check examples/hello_sprite -collection:pkg=. odin check examples/crowd -collection:pkg=. + odin check examples/crowd_batch -collection:pkg=. + odin check examples/crowd_overdraw -collection:pkg=. + odin check examples/clip_thrash -collection:pkg=. odin check examples/camera_sandbox -collection:pkg=. odin check examples/clips -collection:pkg=. @@ -66,6 +72,15 @@ hello_sprite: crowd: odin run examples/crowd -collection:pkg=. +crowd_batch: + odin run examples/crowd_batch -collection:pkg=. + +crowd_overdraw: + odin run examples/crowd_overdraw -collection:pkg=. + +clip_thrash: + odin run examples/clip_thrash -collection:pkg=. + camera_sandbox: odin run examples/camera_sandbox -collection:pkg=. diff --git a/engine/animation_test.odin b/engine/animation_test.odin index 4ce7a31..e95b64e 100644 --- a/engine/animation_test.odin +++ b/engine/animation_test.odin @@ -144,6 +144,37 @@ set_sprite_clip_caches_clip_def :: proc(t: ^testing.T) { testing.expect_value(t, len(sprite.clip_def.frames), 3) } +@(test) +set_sprite_clip_def_applies_and_guards :: proc(t: ^testing.T) { + data := make_test_character() + defer destroy_test_character(&data) + + sprite := spawn_sprite(&data, {}, "idle", 0) + sprite.frame = 2 + sprite.time = 0.05 + + once := data.def.clips["once"] + set_sprite_clip_def(&sprite, "once", once) + testing.expect_value(t, sprite.clip, "once") + testing.expect(t, sprite.has_clip, "def apply should mark clip present") + testing.expect_value(t, sprite.frame, 0) + testing.expect_value(t, sprite.time, f32(0)) + testing.expect(t, !sprite.clip_def.loop, "once clip does not loop") + testing.expect_value(t, len(sprite.clip_def.frames), 3) + + sprite.frame = 1 + sprite.time = 0.09 + set_sprite_clip_def(&sprite, "once", once) + testing.expect_value(t, sprite.frame, 1) + testing.expect_value(t, sprite.time, f32(0.09)) + + set_sprite_clip_def(&sprite, "empty", Clip_Def{loop = true, fps = 10, frames = nil}) + testing.expect_value(t, sprite.clip, "once") + testing.expect_value(t, sprite.frame, 1) + + set_sprite_clip_def(nil, "once", once) +} + @(test) spawn_sprite_valid_and_invalid :: proc(t: ^testing.T) { data := make_test_character() diff --git a/engine/sprite.odin b/engine/sprite.odin index 28b66fc..af6a6be 100644 --- a/engine/sprite.odin +++ b/engine/sprite.odin @@ -183,6 +183,17 @@ set_sprite_clip :: proc(sprite: ^Sprite, clip: string) { def, ok := character_clip(sprite.data, clip) if !ok do return + set_sprite_clip_def(sprite, clip, def) +} + +// Applies a pre-resolved clip without looking up the character clip map. +// Use when callers already hold Clip_Def (e.g. thrashing between known clips). +set_sprite_clip_def :: proc(sprite: ^Sprite, clip: string, def: Clip_Def) { + if sprite == nil do return + if len(def.frames) == 0 do return + + if sprite.clip == clip && sprite.has_clip do return + sprite.clip = clip sprite.clip_def = def sprite.has_clip = true diff --git a/examples/clip_thrash/main.odin b/examples/clip_thrash/main.odin new file mode 100644 index 0000000..8d93c37 --- /dev/null +++ b/examples/clip_thrash/main.odin @@ -0,0 +1,80 @@ +package main + +import "core:fmt" +import eng "pkg:engine" + +// Clip thrash: flip every sprite between idle/walk each frame via set_sprite_clip_def. +COUNT :: eng.MAX_SPRITES +COLS :: 16 +FRAME_LOG_EVERY :: 60 + +main :: proc() { + app: eng.App + if !eng.init(&app, "clip_thrash", 800, 600) do return + defer eng.shutdown(&app) + + data, ok := eng.load_character_data(&app, "assets_baked/characters/toad/toad.char.json") + if !ok do return + defer eng.destroy_character_data(&app, &data) + + idle_def, idle_ok := eng.character_clip(&data, "idle") + walk_def, walk_ok := eng.character_clip(&data, "walk") + if !idle_ok || !walk_ok do return + + sprites: [COUNT]eng.Sprite + for i in 0 ..< COUNT { + col := i % COLS + row := i / COLS + pos := eng.Vec2 { + f32(40 + col * 48), + f32(80 + row * 60), + } + sprites[i] = eng.spawn_sprite(&data, pos, "idle", i % 5) + } + + app.camera.position = {400, 400} + + last := eng.now_seconds() + frame_i := 0 + sum_ms: f64 + peak_ms: f64 + use_walk := false + + for eng.events() { + now := eng.now_seconds() + dt := f32(now - last) + last = now + frame_ms := f64(dt) * 1000.0 + sum_ms += frame_ms + if frame_ms > peak_ms do peak_ms = frame_ms + frame_i += 1 + + if frame_i % FRAME_LOG_EVERY == 0 { + avg := sum_ms / f64(FRAME_LOG_EVERY) + fps := 1000.0 / avg if avg > 0 else 0 + fmt.printfln( + "clip_thrash frame: avg=%.2f ms (%.1f FPS) peak=%.2f ms over %d frames", + avg, + fps, + peak_ms, + FRAME_LOG_EVERY, + ) + sum_ms = 0 + peak_ms = 0 + } + + clip := "walk" if use_walk else "idle" + def := walk_def if use_walk else idle_def + use_walk = !use_walk + for &s in sprites { + eng.set_sprite_clip_def(&s, clip, def) + eng.update_sprite(&s, dt) + } + + eng.begin_frame(&app) + for &s in sprites { + eng.draw_sprite(&app, &s) + } + eng.end_frame(&app) + } +} diff --git a/examples/crowd_batch/main.odin b/examples/crowd_batch/main.odin new file mode 100644 index 0000000..1d254ec --- /dev/null +++ b/examples/crowd_batch/main.odin @@ -0,0 +1,79 @@ +package main + +import "core:fmt" +import eng "pkg:engine" + +// Multi-texture batching stress: two Character_Data (two GPU textures), alternating sprites. +COUNT :: eng.MAX_SPRITES +COLS :: 16 +FRAME_LOG_EVERY :: 60 +BATCH_GROUP :: u32(1) +TOAD_JSON :: "assets_baked/characters/toad/toad.char.json" + +main :: proc() { + app: eng.App + if !eng.init(&app, "crowd_batch", 800, 600) do return + defer eng.shutdown(&app) + + data_a, ok_a := eng.load_character_data(&app, TOAD_JSON) + if !ok_a do return + defer eng.destroy_character_data(&app, &data_a) + + data_b, ok_b := eng.load_character_data(&app, TOAD_JSON) + if !ok_b do return + defer eng.destroy_character_data(&app, &data_b) + + sprites: [COUNT]eng.Sprite + for i in 0 ..< COUNT { + col := i % COLS + row := i / COLS + pos := eng.Vec2 { + f32(40 + col * 48), + f32(80 + row * 60), + } + clip := "idle" if (i % 2) == 0 else "walk" + data := &data_a if (i % 2) == 0 else &data_b + sprites[i] = eng.spawn_sprite(data, pos, clip, i % 5) + } + + app.camera.position = {400, 400} + + last := eng.now_seconds() + frame_i := 0 + sum_ms: f64 + peak_ms: f64 + + for eng.events() { + now := eng.now_seconds() + dt := f32(now - last) + last = now + frame_ms := f64(dt) * 1000.0 + sum_ms += frame_ms + if frame_ms > peak_ms do peak_ms = frame_ms + frame_i += 1 + + if frame_i % FRAME_LOG_EVERY == 0 { + avg := sum_ms / f64(FRAME_LOG_EVERY) + fps := 1000.0 / avg if avg > 0 else 0 + fmt.printfln( + "crowd_batch frame: avg=%.2f ms (%.1f FPS) peak=%.2f ms over %d frames", + avg, + fps, + peak_ms, + FRAME_LOG_EVERY, + ) + sum_ms = 0 + peak_ms = 0 + } + + for &s in sprites { + eng.update_sprite(&s, dt) + } + + eng.begin_frame(&app) + for &s in sprites { + eng.draw_sprite_batched(&app, &s, BATCH_GROUP) + } + eng.end_frame(&app) + } +} diff --git a/examples/crowd_overdraw/main.odin b/examples/crowd_overdraw/main.odin new file mode 100644 index 0000000..3535f66 --- /dev/null +++ b/examples/crowd_overdraw/main.odin @@ -0,0 +1,70 @@ +package main + +import "core:fmt" +import eng "pkg:engine" + +// GPU overdraw stress: MAX_SPRITES stacked near one world point. +COUNT :: eng.MAX_SPRITES +FRAME_LOG_EVERY :: 60 + +main :: proc() { + app: eng.App + if !eng.init(&app, "crowd_overdraw", 800, 600) do return + defer eng.shutdown(&app) + + data, ok := eng.load_character_data(&app, "assets_baked/characters/toad/toad.char.json") + if !ok do return + defer eng.destroy_character_data(&app, &data) + + center := eng.Vec2{400, 400} + sprites: [COUNT]eng.Sprite + for i in 0 ..< COUNT { + jitter := eng.Vec2 { + f32((i % 7) - 3), + f32((i % 5) - 2), + } + clip := "idle" if (i % 2) == 0 else "walk" + sprites[i] = eng.spawn_sprite(&data, center + jitter, clip, i % 5) + } + + app.camera.position = {400, 400} + + last := eng.now_seconds() + frame_i := 0 + sum_ms: f64 + peak_ms: f64 + + for eng.events() { + now := eng.now_seconds() + dt := f32(now - last) + last = now + frame_ms := f64(dt) * 1000.0 + sum_ms += frame_ms + if frame_ms > peak_ms do peak_ms = frame_ms + frame_i += 1 + + if frame_i % FRAME_LOG_EVERY == 0 { + avg := sum_ms / f64(FRAME_LOG_EVERY) + fps := 1000.0 / avg if avg > 0 else 0 + fmt.printfln( + "crowd_overdraw frame: avg=%.2f ms (%.1f FPS) peak=%.2f ms over %d frames", + avg, + fps, + peak_ms, + FRAME_LOG_EVERY, + ) + sum_ms = 0 + peak_ms = 0 + } + + for &s in sprites { + eng.update_sprite(&s, dt) + } + + eng.begin_frame(&app) + for &s in sprites { + eng.draw_sprite(&app, &s) + } + eng.end_frame(&app) + } +}