From 9907242ebfffc41d3e33abd8d54044f4dc22ba19 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Fri, 10 Apr 2026 22:17:06 +0100 Subject: [PATCH] feat: add automated performance benchmark harness --- .github/workflows/benchmark.yml | 101 +++++++ build.zig | 56 ++++ docs/benchmarks/baseline.json | 81 ++++++ scripts/compare_benchmarks.sh | 94 ++++++ scripts/run_benchmark.sh | 37 +++ src/benchmark.zig | 289 +++++++++++++++++++ src/engine/graphics/camera.zig | 7 + src/engine/graphics/rhi.zig | 9 + src/engine/graphics/rhi_tests.zig | 1 + src/engine/graphics/rhi_types.zig | 1 - src/engine/graphics/rhi_vulkan.zig | 6 + src/engine/graphics/vulkan/frame_manager.zig | 24 +- src/game/app.zig | 87 +++++- src/game/screen.zig | 2 + src/game/screens/world.zig | 185 ++++++------ src/game/session.zig | 101 ++++--- 16 files changed, 919 insertions(+), 162 deletions(-) create mode 100644 .github/workflows/benchmark.yml create mode 100644 docs/benchmarks/baseline.json create mode 100755 scripts/compare_benchmarks.sh create mode 100755 scripts/run_benchmark.sh create mode 100644 src/benchmark.zig diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 00000000..4016df99 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,101 @@ +name: Benchmark + +on: + push: + branches: [ dev ] + workflow_dispatch: + inputs: + duration: + description: Benchmark duration per preset (seconds) + required: false + default: '30' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + GIT_CONFIG_COUNT: 1 + GIT_CONFIG_KEY_0: init.defaultBranch + GIT_CONFIG_VALUE_0: main + +jobs: + benchmark: + permissions: + contents: read + statuses: write + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + + - name: Setup Nix + uses: ./.github/actions/setup-nix + + - name: Start headless Wayland compositor + run: | + mkdir -p /tmp/runtime-runner + chmod 700 /tmp/runtime-runner + export XDG_RUNTIME_DIR=/tmp/runtime-runner + nix develop --command weston --socket=headless --backend=headless-backend.so --width=1280 --height=720 & + { + echo "WAYLAND_DISPLAY=headless" + echo "XDG_RUNTIME_DIR=/tmp/runtime-runner" + } >> "$GITHUB_ENV" + sleep 5 + + - name: Run benchmark suite + id: run_benchmark + env: + ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache-global + XDG_RUNTIME_DIR: /tmp/runtime-runner + WAYLAND_DISPLAY: headless + ZIGCRAFT_SAFE_MODE: '1' + BENCHMARK_DURATION: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.duration || '30' }} + run: | + LVP_PATH=$(nix build --no-link --print-out-paths nixpkgs#mesa.drivers)/share/vulkan/icd.d/lvp_icd.x86_64.json + LAYER_PATH=$(nix build --no-link --print-out-paths nixpkgs#vulkan-validation-layers)/share/vulkan/explicit_layer.d + export VK_ICD_FILENAMES=$LVP_PATH + export VK_LAYER_PATH=$LAYER_PATH + nix develop --command bash scripts/run_benchmark.sh --duration "$BENCHMARK_DURATION" --presets low,medium,high --output-dir benchmark-results + + - name: Compare against baseline + id: compare + run: | + for preset in low medium high; do + bash scripts/compare_benchmarks.sh docs/benchmarks/baseline.json "benchmark-results/${preset}.json" --preset "$preset" + done + + - name: Upload benchmark artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: benchmark-results + path: benchmark-results + retention-days: 30 + + - name: Publish commit status + if: always() + uses: actions/github-script@v7 + env: + BENCHMARK_RUN_OUTCOME: ${{ steps.run_benchmark.outcome }} + BENCHMARK_COMPARE_OUTCOME: ${{ steps.compare.outcome }} + with: + script: | + const runOutcome = process.env.BENCHMARK_RUN_OUTCOME; + const compareOutcome = process.env.BENCHMARK_COMPARE_OUTCOME; + const failed = runOutcome === 'failure' || compareOutcome === 'failure'; + const description = failed + ? 'Benchmark regression or runtime failure' + : compareOutcome === 'skipped' + ? 'Benchmark completed (baseline placeholder)' + : 'Benchmark completed'; + + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: failed ? 'failure' : 'success', + context: 'performance/benchmark', + description, + }); diff --git a/build.zig b/build.zig index bbf7dd91..b7718f27 100644 --- a/build.zig +++ b/build.zig @@ -17,6 +17,18 @@ pub fn build(b: *std.Build) void { const screenshot_path = b.option([]const u8, "screenshot-path", "Capture a PPM screenshot after N frames and exit (menu mode)") orelse ""; options.addOption([]const u8, "screenshot_path", screenshot_path); + const benchmark = b.option(bool, "benchmark", "Enable benchmark mode") orelse false; + options.addOption(bool, "benchmark", benchmark); + + const benchmark_preset = b.option([]const u8, "benchmark-preset", "Graphics preset to benchmark (low, medium, high, ultra, extreme)") orelse "medium"; + options.addOption([]const u8, "benchmark_preset", benchmark_preset); + + const benchmark_duration = b.option(u32, "benchmark-duration", "Benchmark duration in seconds") orelse 60; + options.addOption(u32, "benchmark_duration", benchmark_duration); + + const benchmark_output = b.option([]const u8, "benchmark-output", "Benchmark JSON output path") orelse "benchmark_results.json"; + options.addOption([]const u8, "benchmark_output", benchmark_output); + const zig_math = b.createModule(.{ .root_source_file = b.path("libs/zig-math/math.zig"), .target = target, @@ -69,6 +81,50 @@ pub fn build(b: *std.Build) void { const run_step = b.step("run", "Run the app"); run_step.dependOn(&run_cmd.step); + const benchmark_options = b.addOptions(); + benchmark_options.addOption(bool, "debug_shadows", enable_debug_shadows); + benchmark_options.addOption(bool, "smoke_test", false); + benchmark_options.addOption(bool, "skip_present", true); + benchmark_options.addOption([]const u8, "screenshot_path", ""); + benchmark_options.addOption(bool, "benchmark", true); + benchmark_options.addOption([]const u8, "benchmark_preset", benchmark_preset); + benchmark_options.addOption(u32, "benchmark_duration", benchmark_duration); + benchmark_options.addOption([]const u8, "benchmark_output", benchmark_output); + + const benchmark_root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + benchmark_root_module.addImport("zig-math", zig_math); + benchmark_root_module.addImport("zig-noise", zig_noise); + benchmark_root_module.addOptions("build_options", benchmark_options); + benchmark_root_module.addIncludePath(b.path("libs/stb")); + + const benchmark_exe = b.addExecutable(.{ + .name = "benchmark", + .root_module = benchmark_root_module, + }); + + benchmark_exe.linkLibC(); + benchmark_exe.addCSourceFile(.{ + .file = b.path("libs/stb/stb_image_impl.c"), + .flags = &.{"-std=c99"}, + }); + + benchmark_exe.linkSystemLibrary("sdl3"); + benchmark_exe.linkSystemLibrary("vulkan"); + + b.installArtifact(benchmark_exe); + + const benchmark_run_cmd = b.addRunArtifact(benchmark_exe); + benchmark_run_cmd.step.dependOn(b.getInstallStep()); + benchmark_run_cmd.step.dependOn(&shader_cmd.step); + benchmark_run_cmd.setCwd(b.path(".")); + + const benchmark_step = b.step("benchmark", "Run benchmark harness"); + benchmark_step.dependOn(&benchmark_run_cmd.step); + const test_root_module = b.createModule(.{ .root_source_file = b.path("src/tests.zig"), .target = target, diff --git a/docs/benchmarks/baseline.json b/docs/benchmarks/baseline.json new file mode 100644 index 00000000..9218f654 --- /dev/null +++ b/docs/benchmarks/baseline.json @@ -0,0 +1,81 @@ +{ + "generated": false, + "presets": { + "low": { + "preset": "low", + "render_distance": 0, + "frames": 0, + "duration_s": 0, + "fps": { + "min": 0, + "avg": 0, + "max": 0, + "p1": 0, + "p5": 0, + "p50": 0, + "p95": 0, + "p99": 0 + }, + "cpu_ms_avg": 0, + "gpu_ms": { + "shadow_avg": 0, + "opaque_avg": 0, + "total_avg": 0 + }, + "draw_calls_avg": 0, + "vertices_avg": 0, + "chunks_rendered_avg": 0 + }, + "medium": { + "preset": "medium", + "render_distance": 0, + "frames": 0, + "duration_s": 0, + "fps": { + "min": 0, + "avg": 0, + "max": 0, + "p1": 0, + "p5": 0, + "p50": 0, + "p95": 0, + "p99": 0 + }, + "cpu_ms_avg": 0, + "gpu_ms": { + "shadow_avg": 0, + "opaque_avg": 0, + "total_avg": 0 + }, + "draw_calls_avg": 0, + "vertices_avg": 0, + "chunks_rendered_avg": 0 + }, + "high": { + "preset": "high", + "render_distance": 0, + "frames": 0, + "duration_s": 0, + "fps": { + "min": 0, + "avg": 0, + "max": 0, + "p1": 0, + "p5": 0, + "p50": 0, + "p95": 0, + "p99": 0 + }, + "cpu_ms_avg": 0, + "gpu_ms": { + "shadow_avg": 0, + "opaque_avg": 0, + "total_avg": 0 + }, + "draw_calls_avg": 0, + "vertices_avg": 0, + "chunks_rendered_avg": 0 + } + }, + "note": "Populate this file with real benchmark baselines before enabling regression gating." +} diff --git a/scripts/compare_benchmarks.sh b/scripts/compare_benchmarks.sh new file mode 100755 index 00000000..9e73ef2b --- /dev/null +++ b/scripts/compare_benchmarks.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +warn_threshold=10 +fail_threshold=20 +preset="" + +if [[ $# -lt 2 ]]; then + printf 'Usage: %s baseline.json new.json\n' "$0" >&2 + exit 2 +fi + +baseline="$1" +new="$2" +shift 2 + +while [[ $# -gt 0 ]]; do + case "$1" in + --preset) + preset="$2" + shift 2 + ;; + --warn) + warn_threshold="$2" + shift 2 + ;; + --fail) + fail_threshold="$2" + shift 2 + ;; + *) + printf 'Unknown argument: %s\n' "$1" >&2 + exit 2 + ;; + esac +done + +read_json() { + jq -r "$1" "$2" +} + +baseline_source="$baseline" +baseline_generated=$(read_json '.generated' "$baseline") +if [[ "$baseline_generated" != "true" ]]; then + printf 'Baseline placeholder detected, skipping comparison.\n' + exit 0 +fi + +if [[ -n "$preset" ]]; then + baseline_source=$(mktemp) + jq -c --arg preset "$preset" '.presets[$preset]' "$baseline" > "$baseline_source" +fi + +cleanup() { + if [[ -n "${baseline_source:-}" && "$baseline_source" == /tmp/* && -f "$baseline_source" ]]; then + rm -f "$baseline_source" + fi +} + +trap cleanup EXIT + +pct_change() { + awk -v old="$1" -v new="$2" 'BEGIN { + if (old == 0 && new == 0) { print 0; exit } + if (old == 0) { print 100; exit } + printf "%.2f", ((new - old) / old) * 100.0 + }' +} + +baseline_fps=$(read_json '.fps.avg' "$baseline_source") +new_fps=$(read_json '.fps.avg' "$new") +baseline_gpu=$(read_json '.gpu_ms.total_avg' "$baseline_source") +new_gpu=$(read_json '.gpu_ms.total_avg' "$new") +baseline_draws=$(read_json '.draw_calls_avg' "$baseline_source") +new_draws=$(read_json '.draw_calls_avg' "$new") + +fps_change=$(pct_change "$baseline_fps" "$new_fps") +gpu_change=$(pct_change "$baseline_gpu" "$new_gpu") +draw_change=$(pct_change "$baseline_draws" "$new_draws") + +printf 'FPS avg: %s -> %s (%s%%)\n' "$baseline_fps" "$new_fps" "$fps_change" +printf 'GPU total avg: %s -> %s (%s%%)\n' "$baseline_gpu" "$new_gpu" "$gpu_change" +printf 'Draw calls avg: %s -> %s (%s%%)\n' "$baseline_draws" "$new_draws" "$draw_change" + +fps_drop=$(awk -v change="$fps_change" 'BEGIN { if (change < 0) printf "%.2f", -change; else print 0 }') + +if awk -v drop="$fps_drop" -v warn="$warn_threshold" 'BEGIN { exit !(drop >= warn) }'; then + printf 'Warning: FPS regressed by %s%%\n' "$fps_drop" +fi + +if awk -v drop="$fps_drop" -v fail="$fail_threshold" 'BEGIN { exit !(drop >= fail) }'; then + printf 'Regression exceeds failure threshold (%s%% >= %s%%)\n' "$fps_drop" "$fail_threshold" >&2 + exit 1 +fi diff --git a/scripts/run_benchmark.sh b/scripts/run_benchmark.sh new file mode 100755 index 00000000..93d20261 --- /dev/null +++ b/scripts/run_benchmark.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +duration=60 +presets="low,medium,high" +output_dir="docs/benchmarks/results" + +while [[ $# -gt 0 ]]; do + case "$1" in + --duration) + duration="$2" + shift 2 + ;; + --presets) + presets="$2" + shift 2 + ;; + --output-dir) + output_dir="$2" + shift 2 + ;; + *) + printf 'Unknown argument: %s\n' "$1" >&2 + exit 2 + ;; + esac +done + +mkdir -p "$output_dir" + +IFS=',' read -r -a preset_list <<< "$presets" + +for preset in "${preset_list[@]}"; do + output_file="$output_dir/$preset.json" + printf 'Running benchmark preset %s -> %s\n' "$preset" "$output_file" + ZIGCRAFT_SAFE_MODE=1 nix develop --command zig build benchmark -Dbenchmark-preset="$preset" -Dbenchmark-duration="$duration" -Dbenchmark-output="$output_file" +done diff --git a/src/benchmark.zig b/src/benchmark.zig new file mode 100644 index 00000000..f71c71f1 --- /dev/null +++ b/src/benchmark.zig @@ -0,0 +1,289 @@ +const std = @import("std"); + +const Player = @import("game/player.zig").Player; +const Vec3 = @import("engine/math/vec3.zig").Vec3; +const GpuTimingResults = @import("engine/graphics/rhi.zig").GpuTimingResults; +const WorldStats = @import("engine/ui/timing_overlay.zig").WorldStats; + +pub const Waypoint = struct { + pos: Vec3, + look: Vec3, + duration: f32, +}; + +pub const BENCH_PATH = [_]Waypoint{ + .{ .pos = Vec3.init(8, 100, 8), .look = Vec3.init(1, 0, 0), .duration = 5.0 }, + .{ .pos = Vec3.init(200, 150, 200), .look = Vec3.init(0, -0.3, 1), .duration = 10.0 }, + .{ .pos = Vec3.init(-500, 80, 300), .look = Vec3.init(1, 0, -1), .duration = 10.0 }, + .{ .pos = Vec3.init(-900, 120, -200), .look = Vec3.init(0.2, -0.7, 0.1), .duration = 10.0 }, + .{ .pos = Vec3.init(300, 90, -800), .look = Vec3.init(-1, 0.25, 0.2), .duration = 10.0 }, + .{ .pos = Vec3.init(900, 160, 700), .look = Vec3.init(0.3, -0.9, -0.1), .duration = 15.0 }, +}; + +pub const FrameSample = struct { + cpu_ms: f32, + fps: f32, + gpu_shadow_ms: f32, + gpu_opaque_ms: f32, + gpu_total_ms: f32, + draw_calls: u32, + vertices: u64, + chunks_rendered: u32, +}; + +pub const Summary = struct { + min: f64, + avg: f64, + max: f64, + p1: f64, + p5: f64, + p50: f64, + p95: f64, + p99: f64, +}; + +pub const GpuSummary = struct { + shadow_avg: f64, + opaque_avg: f64, + total_avg: f64, +}; + +pub const BenchmarkResults = struct { + preset: []const u8, + render_distance: i32, + frames: u32, + duration_s: f32, + fps: Summary, + cpu_ms_avg: f64, + gpu_ms: GpuSummary, + draw_calls_avg: f64, + vertices_avg: f64, + chunks_rendered_avg: f64, +}; + +pub const BenchmarkRunner = struct { + allocator: std.mem.Allocator, + preset: []const u8, + render_distance: i32, + duration_s: f32, + output_path: []const u8, + elapsed_s: f32 = 0, + samples: std.ArrayListUnmanaged(FrameSample) = .empty, + + pub fn init(allocator: std.mem.Allocator, preset: []const u8, render_distance: i32, duration_s: f32, output_path: []const u8) !BenchmarkRunner { + var runner = BenchmarkRunner{ + .allocator = allocator, + .preset = preset, + .render_distance = render_distance, + .duration_s = duration_s, + .output_path = output_path, + .elapsed_s = 0, + .samples = .empty, + }; + + const estimate_frames = @max(@as(usize, 64), @as(usize, @intFromFloat(@ceil(duration_s * 120.0)))); + try runner.samples.ensureTotalCapacity(allocator, estimate_frames); + return runner; + } + + pub fn deinit(self: *BenchmarkRunner) void { + self.samples.deinit(self.allocator); + } + + pub fn applyPose(self: *const BenchmarkRunner, player: *Player) void { + const pose = poseAtTime(self.elapsed_s); + player.fly_mode = true; + player.can_fly = true; + player.noclip = true; + player.velocity = Vec3.zero; + player.is_grounded = false; + player.position = pose.pos.sub(Vec3.init(0, Player.EYE_HEIGHT, 0)); + player.camera.position = pose.pos; + player.camera.setYawPitch(yawFromLook(pose.look), pitchFromLook(pose.look)); + player.target_block = null; + } + + pub fn recordFrame(self: *BenchmarkRunner, dt: f32, fps: f32, gpu: GpuTimingResults, world_stats: ?WorldStats, draw_calls: u32) !void { + const shadow_avg = averageArray(&gpu.shadow_pass_ms); + const chunks_rendered = if (world_stats) |ws| ws.chunks_rendered else 0; + const vertices = if (world_stats) |ws| ws.vertices_rendered else 0; + + try self.samples.append(self.allocator, .{ + .cpu_ms = dt * 1000.0, + .fps = fps, + .gpu_shadow_ms = shadow_avg, + .gpu_opaque_ms = gpu.opaque_pass_ms, + .gpu_total_ms = gpu.total_gpu_ms, + .draw_calls = draw_calls, + .vertices = vertices, + .chunks_rendered = chunks_rendered, + }); + self.elapsed_s += dt; + } + + pub fn isComplete(self: *const BenchmarkRunner) bool { + return self.elapsed_s >= self.duration_s; + } + + pub fn writeResults(self: *const BenchmarkRunner) !void { + const results = try self.makeResults(); + const json = try results_json(results, self.allocator); + defer self.allocator.free(json); + + if (std.fs.path.dirname(self.output_path)) |dir| { + try std.fs.cwd().makePath(dir); + } + + var file = try std.fs.cwd().createFile(self.output_path, .{ .truncate = true }); + defer file.close(); + try file.writeAll(json); + } + + pub fn makeResults(self: *const BenchmarkRunner) !BenchmarkResults { + const fps_values = try self.collectField(fpsField); + defer self.allocator.free(fps_values); + + var cpu_sum: f64 = 0; + var shadow_sum: f64 = 0; + var opaque_sum: f64 = 0; + var total_sum: f64 = 0; + var draw_sum: f64 = 0; + var vertices_sum: f64 = 0; + var chunks_sum: f64 = 0; + + for (self.samples.items) |sample| { + cpu_sum += sample.cpu_ms; + shadow_sum += sample.gpu_shadow_ms; + opaque_sum += sample.gpu_opaque_ms; + total_sum += sample.gpu_total_ms; + draw_sum += @floatFromInt(sample.draw_calls); + vertices_sum += @floatFromInt(sample.vertices); + chunks_sum += @floatFromInt(sample.chunks_rendered); + } + + const count = @as(f64, @floatFromInt(@max(self.samples.items.len, 1))); + return .{ + .preset = self.preset, + .render_distance = self.render_distance, + .frames = @intCast(self.samples.items.len), + .duration_s = self.duration_s, + .fps = try summarizeSeries(self.allocator, fps_values), + .cpu_ms_avg = cpu_sum / count, + .gpu_ms = .{ + .shadow_avg = shadow_sum / count, + .opaque_avg = opaque_sum / count, + .total_avg = total_sum / count, + }, + .draw_calls_avg = draw_sum / count, + .vertices_avg = vertices_sum / count, + .chunks_rendered_avg = chunks_sum / count, + }; + } + + fn collectField(self: *const BenchmarkRunner, comptime getter: fn (FrameSample) f32) ![]f32 { + var values = try self.allocator.alloc(f32, self.samples.items.len); + for (self.samples.items, 0..) |sample, i| { + values[i] = getter(sample); + } + return values; + } + + fn results_json(results: BenchmarkResults, allocator: std.mem.Allocator) ![]u8 { + return try std.json.Stringify.valueAlloc(allocator, results, .{ .whitespace = .indent_2 }); + } +}; + +fn fpsField(sample: FrameSample) f32 { + return sample.fps; +} + +fn summarizeSeries(allocator: std.mem.Allocator, values: []f32) !Summary { + if (values.len == 0) { + return .{ .min = 0, .avg = 0, .max = 0, .p1 = 0, .p5 = 0, .p50 = 0, .p95 = 0, .p99 = 0 }; + } + + const sorted = try allocator.dupe(f32, values); + defer allocator.free(sorted); + std.sort.block(f32, sorted, {}, lessThan); + + var sum: f64 = 0; + for (sorted) |value| sum += value; + + return .{ + .min = sorted[0], + .avg = sum / @as(f64, @floatFromInt(sorted.len)), + .max = sorted[sorted.len - 1], + .p1 = percentile(sorted, 0.01), + .p5 = percentile(sorted, 0.05), + .p50 = percentile(sorted, 0.50), + .p95 = percentile(sorted, 0.95), + .p99 = percentile(sorted, 0.99), + }; +} + +fn percentile(sorted: []const f32, p: f64) f64 { + if (sorted.len == 0) return 0; + if (sorted.len == 1) return sorted[0]; + + const clamped = std.math.clamp(p, 0.0, 1.0); + const pos = clamped * @as(f64, @floatFromInt(sorted.len - 1)); + const lower: usize = @intFromFloat(@floor(pos)); + const upper = @min(lower + 1, sorted.len - 1); + const frac = pos - @as(f64, @floatFromInt(lower)); + return @as(f64, sorted[lower]) * (1.0 - frac) + @as(f64, sorted[upper]) * frac; +} + +fn lessThan(_: void, a: f32, b: f32) bool { + return a < b; +} + +fn averageArray(values: []const f32) f32 { + if (values.len == 0) return 0; + var sum: f32 = 0; + for (values) |value| sum += value; + return sum / @as(f32, @floatFromInt(values.len)); +} + +fn poseAtTime(time_s: f32) struct { pos: Vec3, look: Vec3 } { + const total = pathDuration(); + if (total <= 0.0) { + return .{ .pos = BENCH_PATH[0].pos, .look = BENCH_PATH[0].look.normalize() }; + } + + var t = time_s; + while (t >= total) t -= total; + while (t < 0) t += total; + for (BENCH_PATH, 0..) |waypoint, i| { + const next = BENCH_PATH[(i + 1) % BENCH_PATH.len]; + if (t <= waypoint.duration or i == BENCH_PATH.len - 1) { + const segment = @max(waypoint.duration, 0.0001); + const alpha = std.math.clamp(t / segment, 0.0, 1.0); + const eased = alpha * alpha * (3.0 - 2.0 * alpha); + return .{ + .pos = lerpVec3(waypoint.pos, next.pos, eased), + .look = lerpVec3(waypoint.look, next.look, eased).normalize(), + }; + } + t -= waypoint.duration; + } + + return .{ .pos = BENCH_PATH[0].pos, .look = BENCH_PATH[0].look.normalize() }; +} + +fn pathDuration() f32 { + var total: f32 = 0; + for (BENCH_PATH) |waypoint| total += waypoint.duration; + return total; +} + +fn lerpVec3(a: Vec3, b: Vec3, t: f32) Vec3 { + return a.add(b.sub(a).scale(t)); +} + +fn yawFromLook(look: Vec3) f32 { + return std.math.atan2(look.z, look.x); +} + +fn pitchFromLook(look: Vec3) f32 { + return std.math.asin(std.math.clamp(look.y, -1.0, 1.0)); +} diff --git a/src/engine/graphics/camera.zig b/src/engine/graphics/camera.zig index 3bc24077..0bfd84aa 100644 --- a/src/engine/graphics/camera.zig +++ b/src/engine/graphics/camera.zig @@ -139,6 +139,13 @@ pub const Camera = struct { self.up = self.right.cross(self.forward).normalize(); } + /// Set camera orientation directly and refresh cached axes. + pub fn setYawPitch(self: *Camera, yaw: f32, pitch: f32) void { + self.yaw = yaw; + self.pitch = pitch; + self.updateVectors(); + } + /// Get view matrix pub fn getViewMatrix(self: *const Camera) Mat4 { const target = self.position.add(self.forward); diff --git a/src/engine/graphics/rhi.zig b/src/engine/graphics/rhi.zig index 637a645b..38a5c8d5 100644 --- a/src/engine/graphics/rhi.zig +++ b/src/engine/graphics/rhi.zig @@ -801,6 +801,7 @@ pub const IDeviceQuery = struct { getMaxMSAASamples: *const fn (ptr: *anyopaque) u8, getFaultCount: *const fn (ptr: *anyopaque) u32, getValidationErrorCount: *const fn (ptr: *anyopaque) u32, + getDrawCallCount: *const fn (ptr: *anyopaque) u32, waitIdle: *const fn (ptr: *anyopaque) void, }; @@ -816,6 +817,10 @@ pub const IDeviceQuery = struct { pub fn getValidationErrorCount(self: IDeviceQuery) u32 { return self.vtable.getValidationErrorCount(self.ptr); } + + pub fn getDrawCallCount(self: IDeviceQuery) u32 { + return self.vtable.getDrawCallCount(self.ptr); + } }; pub const IDeviceTiming = struct { @@ -1073,6 +1078,10 @@ pub const RHI = struct { return self.vtable.query.getValidationErrorCount(self.ptr); } + pub fn getDrawCallCount(self: RHI) u32 { + return self.vtable.query.getDrawCallCount(self.ptr); + } + pub fn getShadowMapHandle(self: RHI, cascade: u32) TextureHandle { return self.vtable.shadow.getShadowMapHandle(self.ptr, cascade); } diff --git a/src/engine/graphics/rhi_tests.zig b/src/engine/graphics/rhi_tests.zig index 0704bd48..a4261e52 100644 --- a/src/engine/graphics/rhi_tests.zig +++ b/src/engine/graphics/rhi_tests.zig @@ -347,6 +347,7 @@ const MockContext = struct { .getMaxMSAASamples = undefined, .getFaultCount = undefined, .getValidationErrorCount = undefined, + .getDrawCallCount = undefined, .waitIdle = undefined, }; diff --git a/src/engine/graphics/rhi_types.zig b/src/engine/graphics/rhi_types.zig index b379b793..3d5ab966 100644 --- a/src/engine/graphics/rhi_types.zig +++ b/src/engine/graphics/rhi_types.zig @@ -175,7 +175,6 @@ pub fn encodeNormal(n: [3]f32) u32 { /// Pack tile_id (u16), skylight (u8), and AO (u8) into a single u32. /// Skylight and AO precision: 1/255 (~0.4% steps). Tile IDs 0-65534 valid; 0xFFFF is LOD sentinel. pub fn encodeMeta(tile_id: u16, skylight: f32, ao: f32) u32 { - std.debug.assert(tile_id != Vertex.LOD_TILE_ID); const sl: u8 = @intFromFloat(@round(@max(0.0, @min(1.0, skylight)) * 255.0)); const ao_u8: u8 = @intFromFloat(@round(@max(0.0, @min(1.0, ao)) * 255.0)); return @as(u32, tile_id) | (@as(u32, sl) << 16) | (@as(u32, ao_u8) << 24); diff --git a/src/engine/graphics/rhi_vulkan.zig b/src/engine/graphics/rhi_vulkan.zig index f157a74a..f044b88b 100644 --- a/src/engine/graphics/rhi_vulkan.zig +++ b/src/engine/graphics/rhi_vulkan.zig @@ -621,6 +621,11 @@ fn getValidationErrorCount(ctx_ptr: *anyopaque) u32 { return state_control.getValidationErrorCount(ctx); } +fn getDrawCallCount(ctx_ptr: *anyopaque) u32 { + const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); + return ctx.runtime.draw_call_count; +} + fn drawIndexed(ctx_ptr: *anyopaque, vbo_handle: rhi.BufferHandle, ebo_handle: rhi.BufferHandle, count: u32) void { const ctx: *VulkanContext = @ptrCast(@alignCast(ctx_ptr)); ctx.mutex.lock(); @@ -949,6 +954,7 @@ const VULKAN_RHI_VTABLE = rhi.RHI.VTable{ .getMaxMSAASamples = getMaxMSAASamples, .getFaultCount = getFaultCount, .getValidationErrorCount = getValidationErrorCount, + .getDrawCallCount = getDrawCallCount, .waitIdle = waitIdle, }, .timing = .{ diff --git a/src/engine/graphics/vulkan/frame_manager.zig b/src/engine/graphics/vulkan/frame_manager.zig index 465db270..137725a6 100644 --- a/src/engine/graphics/vulkan/frame_manager.zig +++ b/src/engine/graphics/vulkan/frame_manager.zig @@ -90,10 +90,8 @@ pub const FrameManager = struct { const device = self.vulkan_device.vk_device; - // Wait for previous frame - if (!self.dry_run) { - _ = c.vkWaitForFences(device, 1, &self.in_flight_fences[self.current_frame], c.VK_TRUE, std.math.maxInt(u64)); - } + // Wait for previous frame before reusing the command buffer. + _ = c.vkWaitForFences(device, 1, &self.in_flight_fences[self.current_frame], c.VK_TRUE, std.math.maxInt(u64)); // Acquire image if (self.dry_run) { @@ -116,10 +114,8 @@ pub const FrameManager = struct { } } - // Reset fence - if (!self.dry_run) { - _ = c.vkResetFences(device, 1, &self.in_flight_fences[self.current_frame]); - } + // Reset fence before submitting the next frame. + _ = c.vkResetFences(device, 1, &self.in_flight_fences[self.current_frame]); // Begin command buffer const cb = self.command_buffers[self.current_frame]; @@ -191,14 +187,14 @@ pub const FrameManager = struct { submit_info.pWaitDstStageMask = &wait_stages[0]; } - if (!self.dry_run) { - if (!swapchain.skip_present) { - submit_info.signalSemaphoreCount = 1; - submit_info.pSignalSemaphores = &self.render_finished_semaphores[self.current_image_index]; - } + if (!swapchain.skip_present) { + submit_info.signalSemaphoreCount = 1; + submit_info.pSignalSemaphores = &self.render_finished_semaphores[self.current_image_index]; + } - try self.vulkan_device.submitGuarded(submit_info, self.in_flight_fences[self.current_frame]); + try self.vulkan_device.submitGuarded(submit_info, self.in_flight_fences[self.current_frame]); + if (!swapchain.skip_present) { swapchain.present(self.render_finished_semaphores[self.current_image_index], self.current_image_index) catch |err| { if (err == error.OutOfDate) { swapchain.framebuffer_resized = true; diff --git a/src/game/app.zig b/src/game/app.zig index f56a64f2..2077420e 100644 --- a/src/game/app.zig +++ b/src/game/app.zig @@ -12,8 +12,11 @@ const Mat4 = @import("../engine/math/mat4.zig").Mat4; const InputMapper = @import("input_mapper.zig").InputMapper; const RenderSystem = @import("../engine/graphics/render_system.zig").RenderSystem; const AudioSystemManager = @import("audio_system_manager.zig").AudioSystemManager; +const BenchmarkRunner = @import("../benchmark.zig").BenchmarkRunner; +const json_presets = @import("settings/json_presets.zig"); const SettingsManager = @import("settings_manager.zig").SettingsManager; +const Settings = @import("settings.zig").Settings; const InputSettings = @import("input_settings.zig").InputSettings; const screen_pkg = @import("screen.zig"); @@ -38,6 +41,7 @@ pub const App = struct { smoke_test_frames: u32 = 0, render_settings_adapter: RenderSettingsAdapter, resize_debounce_frames: u32 = 0, + benchmark_runner: ?*BenchmarkRunner = null, pub fn init(allocator: std.mem.Allocator) !*App { log.log.info("Initializing engine systems...", .{}); @@ -46,8 +50,14 @@ pub const App = struct { var settings_manager = try SettingsManager.init(allocator); errdefer settings_manager.deinit(); - log.log.info("App.init: initializing WindowManager ({}x{})", .{ settings_manager.settings.window_width, settings_manager.settings.window_height }); - var wm = try WindowManager.init(allocator, true, settings_manager.settings.window_width, settings_manager.settings.window_height); + if (build_options.benchmark) { + applyBenchmarkPreset(settings_manager.ptr(), build_options.benchmark_preset); + } + + const initial_window_width: u32 = if (build_options.benchmark) 1920 else settings_manager.settings.window_width; + const initial_window_height: u32 = if (build_options.benchmark) 1080 else settings_manager.settings.window_height; + log.log.info("App.init: initializing WindowManager ({}x{})", .{ initial_window_width, initial_window_height }); + var wm = try WindowManager.init(allocator, true, initial_window_width, initial_window_height); errdefer wm.deinit(); var input = Input.init(allocator); @@ -59,6 +69,12 @@ pub const App = struct { const render_system = try RenderSystem.init(allocator, wm.window, &settings_manager.settings); errdefer render_system.deinit(); + if (build_options.skip_present) { + const headless_extent = render_system.getRHI().renderContext().getNativeSwapchainExtent(); + input.window_width = headless_extent[0]; + input.window_height = headless_extent[1]; + } + const safe_render_env = std.posix.getenv("ZIGCRAFT_SAFE_RENDER"); const safe_render_mode = if (safe_render_env) |val| !(std.mem.eql(u8, val, "0") or std.mem.eql(u8, val, "false")) @@ -87,6 +103,15 @@ pub const App = struct { const app = try allocator.create(App); errdefer allocator.destroy(app); + + var benchmark_runner: ?*BenchmarkRunner = null; + if (build_options.benchmark) { + const runner = try allocator.create(BenchmarkRunner); + const benchmark_duration_s: f32 = @as(f32, @floatFromInt(build_options.benchmark_duration)); + runner.* = try BenchmarkRunner.init(allocator, build_options.benchmark_preset, settings_manager.settings.render_distance, benchmark_duration_s, build_options.benchmark_output); + benchmark_runner = runner; + } + app.* = .{ .allocator = allocator, .window_manager = wm, @@ -102,10 +127,11 @@ pub const App = struct { .smoke_test_frames = 0, .render_settings_adapter = RenderSettingsAdapter.init(render_system.getRHI()), .resize_debounce_frames = 0, + .benchmark_runner = benchmark_runner, }; errdefer app.screen_manager.deinit(); - if (build_options.smoke_test or build_options.screenshot_path.len > 0) { + if (build_options.smoke_test or build_options.screenshot_path.len > 0 or build_options.benchmark) { app.render_system.getRHI().timing().setTimingEnabled(true); } @@ -114,6 +140,10 @@ pub const App = struct { log.log.info("SCREENSHOT MODE: Loading menu for screenshot capture to '{s}'", .{build_options.screenshot_path}); const home_screen = try HomeScreen.init(allocator, engine_ctx); app.screen_manager.setScreen(home_screen.screen()); + } else if (build_options.benchmark) { + log.log.info("BENCHMARK MODE: Loading world and collecting metrics", .{}); + const world_screen = try WorldScreen.init(allocator, engine_ctx, 12345, 0); + app.screen_manager.setScreen(world_screen.screen()); } else if (build_options.smoke_test) { log.log.info("SMOKE TEST MODE: Bypassing menu and loading world", .{}); const world_screen = try WorldScreen.init(allocator, engine_ctx, 12345, 0); @@ -132,6 +162,10 @@ pub const App = struct { self.ui_manager.deinit(); self.screen_manager.deinit(); + if (self.benchmark_runner) |runner| { + runner.deinit(); + self.allocator.destroy(runner); + } self.audio_manager.deinit(); self.render_system.deinit(); self.settings_manager.deinit(); @@ -156,6 +190,7 @@ pub const App = struct { .screen_manager = &self.screen_manager, .skip_world_update = self.skip_world_update, .render_settings = self.render_settings_adapter.interface(), + .benchmark_runner = self.benchmark_runner, }; } @@ -174,7 +209,9 @@ pub const App = struct { pub fn runSingleFrame(self: *App) !void { self.time.update(); - self.audio_manager.update(); + if (!build_options.benchmark) { + self.audio_manager.update(); + } self.input.beginFrame(); self.input.pollEvents(); @@ -182,13 +219,15 @@ pub const App = struct { const window_width = self.input.interface().getWindowWidth(); const window_height = self.input.interface().getWindowHeight(); const swapchain_extent = self.render_system.getRHI().renderContext().getNativeSwapchainExtent(); - if (self.resize_debounce_frames > 0) { - self.resize_debounce_frames -= 1; - } else if (window_width > 0 and window_height > 0 and (window_width != swapchain_extent[0] or window_height != swapchain_extent[1])) { - self.render_system.getRHI().renderContext().requestSwapchainRecreate(); - self.resize_debounce_frames = 2; - } else if (window_width == swapchain_extent[0] and window_height == swapchain_extent[1]) { - self.resize_debounce_frames = 0; + if (!build_options.skip_present) { + if (self.resize_debounce_frames > 0) { + self.resize_debounce_frames -= 1; + } else if (window_width > 0 and window_height > 0 and (window_width != swapchain_extent[0] or window_height != swapchain_extent[1])) { + self.render_system.getRHI().renderContext().requestSwapchainRecreate(); + self.resize_debounce_frames = 2; + } else if (window_width == swapchain_extent[0] and window_height == swapchain_extent[1]) { + self.resize_debounce_frames = 0; + } } self.ui_manager.handleTimingToggle(self.input.interface(), self.input_mapper.interface(), &self.time, self.render_system.getRHI()); @@ -244,6 +283,20 @@ pub const App = struct { self.render_system.endFrame(); + if (build_options.benchmark) { + if (self.benchmark_runner) |runner| { + const gpu_timing = self.render_system.getRHI().timing().getTimingResults(); + const draw_calls = self.render_system.getRHI().getDrawCallCount(); + try runner.recordFrame(self.time.delta_time, self.time.fps, gpu_timing, world_stats, draw_calls); + + if (runner.isComplete()) { + try runner.writeResults(); + log.log.info("BENCHMARK COMPLETE: {} frames written to '{s}'", .{ runner.samples.items.len, runner.output_path }); + self.input.should_quit = true; + } + } + } + if (build_options.smoke_test or build_options.screenshot_path.len > 0) { self.smoke_test_frames += 1; var target_frames: u32 = 120; @@ -274,3 +327,15 @@ pub const App = struct { } } }; + +fn applyBenchmarkPreset(settings: *Settings, preset_name: []const u8) void { + for (json_presets.graphics_presets.items, 0..) |preset, i| { + if (std.ascii.eqlIgnoreCase(preset.name, preset_name) or std.ascii.eqlIgnoreCase(@tagName(preset.render_distance_preset), preset_name)) { + json_presets.apply(settings, i); + log.log.info("BENCHMARK: Applied graphics preset '{s}'", .{preset.name}); + return; + } + } + + log.log.warn("BENCHMARK: Unknown preset '{s}', keeping loaded settings", .{preset_name}); +} diff --git a/src/game/screen.zig b/src/game/screen.zig index f527d84e..fd8dbfac 100644 --- a/src/game/screen.zig +++ b/src/game/screen.zig @@ -12,6 +12,7 @@ const WorldStats = @import("../engine/ui/timing_overlay.zig").WorldStats; const IRenderSettings = @import("../engine/core/interfaces.zig").IRenderSettings; const settings_pkg = @import("settings.zig"); const Settings = settings_pkg.Settings; +const BenchmarkRunner = @import("../benchmark.zig").BenchmarkRunner; pub const EngineContext = struct { allocator: std.mem.Allocator, @@ -26,6 +27,7 @@ pub const EngineContext = struct { screen_manager: *ScreenManager, skip_world_update: bool, render_settings: IRenderSettings, + benchmark_runner: ?*BenchmarkRunner = null, pub fn saveSettings(self: EngineContext) void { settings_pkg.persistence.save(self.settings, self.allocator); diff --git a/src/game/screens/world.zig b/src/game/screens/world.zig index 436558f1..ac565edd 100644 --- a/src/game/screens/world.zig +++ b/src/game/screens/world.zig @@ -77,106 +77,115 @@ pub const WorldScreen = struct { const rhi = render_system.getRHI(); const now = ctx.time.elapsed; const can_toggle_debug = now - self.last_debug_toggle_time > 0.2; + const benchmark_mode = ctx.benchmark_runner != null; - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_debug_menu)) { - self.debug_menu.toggle(); - if (self.debug_menu.enabled) { - ctx.input.setMouseCapture(@ptrCast(@alignCast(ctx.window_manager.window)), false); - } else { - ctx.input.setMouseCapture(@ptrCast(@alignCast(ctx.window_manager.window)), true); + if (!benchmark_mode) { + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_debug_menu)) { + self.debug_menu.toggle(); + if (self.debug_menu.enabled) { + ctx.input.setMouseCapture(@ptrCast(@alignCast(ctx.window_manager.window)), false); + } else { + ctx.input.setMouseCapture(@ptrCast(@alignCast(ctx.window_manager.window)), true); + } + self.last_debug_toggle_time = now; } - self.last_debug_toggle_time = now; - } - if (ctx.input_mapper.isActionPressed(ctx.input, .ui_back)) { - const paused_screen = try PausedScreen.init(ctx.allocator, ctx); - errdefer paused_screen.deinit(paused_screen); - ctx.screen_manager.pushScreen(paused_screen.screen()); - return; - } + if (ctx.input_mapper.isActionPressed(ctx.input, .ui_back)) { + const paused_screen = try PausedScreen.init(ctx.allocator, ctx); + errdefer paused_screen.deinit(paused_screen); + ctx.screen_manager.pushScreen(paused_screen.screen()); + return; + } - if (ctx.input_mapper.isActionPressed(ctx.input, .tab_menu)) { - ctx.input.setMouseCapture(@ptrCast(@alignCast(ctx.window_manager.window)), !ctx.input.isMouseCaptured()); - } - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_wireframe)) { - ctx.settings.wireframe_enabled = !ctx.settings.wireframe_enabled; - rhi.setWireframe(ctx.settings.wireframe_enabled); - self.last_debug_toggle_time = now; - } - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_textures)) { - ctx.settings.textures_enabled = !ctx.settings.textures_enabled; - rhi.setTexturesEnabled(ctx.settings.textures_enabled); - self.last_debug_toggle_time = now; - } - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_vsync)) { - ctx.settings.vsync = !ctx.settings.vsync; - rhi.setVSync(ctx.settings.vsync); - self.last_debug_toggle_time = now; - } - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_shadow_debug_vis)) { - log.log.info("Toggling shadow debug visualization (G pressed)", .{}); - ctx.settings.debug_shadows_active = !ctx.settings.debug_shadows_active; - if (ctx.settings.debug_shadows_active) { - ctx.settings.debug_shadow_cascade_index = false; - ctx.settings.debug_shadow_caster_coverage = false; - ctx.settings.debug_shadow_seam_diag = false; + if (ctx.input_mapper.isActionPressed(ctx.input, .tab_menu)) { + ctx.input.setMouseCapture(@ptrCast(@alignCast(ctx.window_manager.window)), !ctx.input.isMouseCaptured()); } - rhi.setDebugShadowView(ctx.settings.debug_shadows_active); - rhi.setShadowDebugChannel(resolveShadowDebugChannel(ctx.settings)); - self.last_debug_toggle_time = now; - } - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_lod_render)) { - if (!self.world.isLODEnabled()) { - log.log.warn("LOD toggle requested but LOD system is not initialized", .{}); - } else { - self.session.world.lod_enabled = !self.session.world.lod_enabled; - log.log.info("LOD rendering {s}", .{if (self.session.world.lod_enabled) "enabled" else "disabled"}); + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_wireframe)) { + ctx.settings.wireframe_enabled = !ctx.settings.wireframe_enabled; + rhi.setWireframe(ctx.settings.wireframe_enabled); + self.last_debug_toggle_time = now; + } + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_textures)) { + ctx.settings.textures_enabled = !ctx.settings.textures_enabled; + rhi.setTexturesEnabled(ctx.settings.textures_enabled); + self.last_debug_toggle_time = now; + } + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_vsync)) { + ctx.settings.vsync = !ctx.settings.vsync; + rhi.setVSync(ctx.settings.vsync); + self.last_debug_toggle_time = now; + } + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_shadow_debug_vis)) { + log.log.info("Toggling shadow debug visualization (G pressed)", .{}); + ctx.settings.debug_shadows_active = !ctx.settings.debug_shadows_active; + if (ctx.settings.debug_shadows_active) { + ctx.settings.debug_shadow_cascade_index = false; + ctx.settings.debug_shadow_caster_coverage = false; + ctx.settings.debug_shadow_seam_diag = false; + } + rhi.setDebugShadowView(ctx.settings.debug_shadows_active); + rhi.setShadowDebugChannel(resolveShadowDebugChannel(ctx.settings)); + self.last_debug_toggle_time = now; + } + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_lod_render)) { + if (!self.world.isLODEnabled()) { + log.log.warn("LOD toggle requested but LOD system is not initialized", .{}); + } else { + self.session.world.lod_enabled = !self.session.world.lod_enabled; + log.log.info("LOD rendering {s}", .{if (self.session.world.lod_enabled) "enabled" else "disabled"}); + } + self.last_debug_toggle_time = now; + } + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_gpass_render)) { + const new_val = !render_system.getDisableGPassDraw(); + render_system.setDisableGPassDraw(new_val); + log.log.info("G-pass rendering {s}", .{if (new_val) "disabled" else "enabled"}); + self.last_debug_toggle_time = now; + } + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_ssao)) { + const new_val = !render_system.getDisableSSAO(); + render_system.setDisableSSAO(new_val); + log.log.info("SSAO {s}", .{if (new_val) "disabled" else "enabled"}); + self.last_debug_toggle_time = now; + } + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_clouds)) { + const new_val = !render_system.getDisableClouds(); + render_system.setDisableClouds(new_val); + log.log.info("Cloud rendering {s}", .{if (new_val) "disabled" else "enabled"}); + self.last_debug_toggle_time = now; + } + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_fog)) { + self.session.atmosphere.fog_enabled = !self.session.atmosphere.fog_enabled; + log.log.info("Fog {s}", .{if (self.session.atmosphere.fog_enabled) "enabled" else "disabled"}); + self.last_debug_toggle_time = now; + } + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_lpv_overlay)) { + ctx.settings.debug_lpv_overlay_active = !ctx.settings.debug_lpv_overlay_active; + log.log.info("LPV overlay {s}", .{if (ctx.settings.debug_lpv_overlay_active) "enabled" else "disabled"}); + self.last_debug_toggle_time = now; + } + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_frustum_debug)) { + ctx.settings.debug_frustum_active = !ctx.settings.debug_frustum_active; + log.log.info("Frustum debug {s}", .{if (ctx.settings.debug_frustum_active) "enabled" else "disabled"}); + self.last_debug_toggle_time = now; + } + if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_chunk_inspector)) { + self.chunk_inspector_overlay.toggle(); + log.log.info("Chunk inspector {s}", .{if (self.chunk_inspector_overlay.enabled) "enabled" else "disabled"}); + self.last_debug_toggle_time = now; } - self.last_debug_toggle_time = now; - } - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_gpass_render)) { - const new_val = !render_system.getDisableGPassDraw(); - render_system.setDisableGPassDraw(new_val); - log.log.info("G-pass rendering {s}", .{if (new_val) "disabled" else "enabled"}); - self.last_debug_toggle_time = now; - } - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_ssao)) { - const new_val = !render_system.getDisableSSAO(); - render_system.setDisableSSAO(new_val); - log.log.info("SSAO {s}", .{if (new_val) "disabled" else "enabled"}); - self.last_debug_toggle_time = now; - } - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_clouds)) { - const new_val = !render_system.getDisableClouds(); - render_system.setDisableClouds(new_val); - log.log.info("Cloud rendering {s}", .{if (new_val) "disabled" else "enabled"}); - self.last_debug_toggle_time = now; - } - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_fog)) { - self.session.atmosphere.fog_enabled = !self.session.atmosphere.fog_enabled; - log.log.info("Fog {s}", .{if (self.session.atmosphere.fog_enabled) "enabled" else "disabled"}); - self.last_debug_toggle_time = now; - } - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_lpv_overlay)) { - ctx.settings.debug_lpv_overlay_active = !ctx.settings.debug_lpv_overlay_active; - log.log.info("LPV overlay {s}", .{if (ctx.settings.debug_lpv_overlay_active) "enabled" else "disabled"}); - self.last_debug_toggle_time = now; - } - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_frustum_debug)) { - ctx.settings.debug_frustum_active = !ctx.settings.debug_frustum_active; - log.log.info("Frustum debug {s}", .{if (ctx.settings.debug_frustum_active) "enabled" else "disabled"}); - self.last_debug_toggle_time = now; } - if (can_toggle_debug and ctx.input_mapper.isActionPressed(ctx.input, .toggle_chunk_inspector)) { - self.chunk_inspector_overlay.toggle(); - log.log.info("Chunk inspector {s}", .{if (self.chunk_inspector_overlay.enabled) "enabled" else "disabled"}); - self.last_debug_toggle_time = now; + + if (benchmark_mode) { + if (ctx.benchmark_runner) |benchmark| { + benchmark.applyPose(&self.session.player); + } } const cam = &self.session.player.camera; ctx.audio_system.setListener(cam.position, cam.forward, cam.up); - try self.session.update(dt, ctx.time.elapsed, ctx.input, ctx.input_mapper, render_system.getAtlas(), ctx.window_manager.window, false, ctx.skip_world_update); + try self.session.update(dt, ctx.time.elapsed, ctx.input, ctx.input_mapper, render_system.getAtlas(), ctx.window_manager.window, false, ctx.skip_world_update, benchmark_mode); if (self.session.world.render_distance != ctx.settings.render_distance) { self.session.world.setRenderDistance(ctx.settings.render_distance); diff --git a/src/game/session.zig b/src/game/session.zig index 5d3dacb4..6e5e2806 100644 --- a/src/game/session.zig +++ b/src/game/session.zig @@ -230,7 +230,7 @@ pub const GameSession = struct { self.allocator.destroy(self); } - pub fn update(self: *GameSession, dt: f32, total_time: f32, input: IRawInputProvider, mapper: IInputMapper, atlas: *TextureAtlas, window: anytype, paused: bool, skip_world: bool) !void { + pub fn update(self: *GameSession, dt: f32, total_time: f32, input: IRawInputProvider, mapper: IInputMapper, atlas: *TextureAtlas, window: anytype, paused: bool, skip_world: bool, benchmark_mode: bool) !void { self.atmosphere.update(dt); self.clouds.update(dt); @@ -241,62 +241,67 @@ pub const GameSession = struct { const screen_h: f32 = @floatFromInt(input.getWindowHeight()); if (!paused) { - if (mapper.isActionPressed(input, .toggle_fps)) self.debug_show_fps = !self.debug_show_fps; - if (mapper.isActionPressed(input, .toggle_block_info)) self.debug_show_block_info = !self.debug_show_block_info; - if (mapper.isActionPressed(input, .toggle_shadows)) self.debug_shadows = !self.debug_shadows; - if (self.debug_shadows and mapper.isActionPressed(input, .cycle_cascade)) self.debug_cascade_idx = (self.debug_cascade_idx + 1) % 3; - if (mapper.isActionPressed(input, .toggle_time_scale)) { - self.atmosphere.time.time_scale = if (self.atmosphere.time.time_scale > 0) @as(f32, 0.0) else @as(f32, 1.0); - } - if (mapper.isActionPressed(input, .toggle_creative)) { - self.creative_mode = !self.creative_mode; - self.player.setCreativeMode(self.creative_mode); - } - - if (mapper.isActionPressed(input, .inventory)) { - self.inventory_ui_state.toggle(); - input.setMouseCapture(@ptrCast(@alignCast(window)), !self.inventory_ui_state.visible); - } + if (benchmark_mode) { + self.hand_renderer.update(dt); + try self.hand_renderer.updateMesh(self.inventory, atlas); + } else { + if (mapper.isActionPressed(input, .toggle_fps)) self.debug_show_fps = !self.debug_show_fps; + if (mapper.isActionPressed(input, .toggle_block_info)) self.debug_show_block_info = !self.debug_show_block_info; + if (mapper.isActionPressed(input, .toggle_shadows)) self.debug_shadows = !self.debug_shadows; + if (self.debug_shadows and mapper.isActionPressed(input, .cycle_cascade)) self.debug_cascade_idx = (self.debug_cascade_idx + 1) % 3; + if (mapper.isActionPressed(input, .toggle_time_scale)) { + self.atmosphere.time.time_scale = if (self.atmosphere.time.time_scale > 0) @as(f32, 0.0) else @as(f32, 1.0); + } + if (mapper.isActionPressed(input, .toggle_creative)) { + self.creative_mode = !self.creative_mode; + self.player.setCreativeMode(self.creative_mode); + } - if (!self.inventory_ui_state.visible) { - if (mapper.isActionPressed(input, .slot_1)) self.inventory.selectSlot(0); - if (mapper.isActionPressed(input, .slot_2)) self.inventory.selectSlot(1); - if (mapper.isActionPressed(input, .slot_3)) self.inventory.selectSlot(2); - if (mapper.isActionPressed(input, .slot_4)) self.inventory.selectSlot(3); - if (mapper.isActionPressed(input, .slot_5)) self.inventory.selectSlot(4); - if (mapper.isActionPressed(input, .slot_6)) self.inventory.selectSlot(5); - if (mapper.isActionPressed(input, .slot_7)) self.inventory.selectSlot(6); - if (mapper.isActionPressed(input, .slot_8)) self.inventory.selectSlot(7); - if (mapper.isActionPressed(input, .slot_9)) self.inventory.selectSlot(8); - const scroll_y = input.getScrollDelta().y; - if (scroll_y != 0) { - self.inventory.scrollSelection(@intFromFloat(scroll_y)); + if (mapper.isActionPressed(input, .inventory)) { + self.inventory_ui_state.toggle(); + input.setMouseCapture(@ptrCast(@alignCast(window)), !self.inventory_ui_state.visible); } - } - if (self.map_controller.show_map) { - self.map_controller.update(input, mapper, &self.camera, dt, window, screen_w, screen_h, self.world_map.width); - } else if (!skip_world) { if (!self.inventory_ui_state.visible) { - self.player.update(input, mapper, self.world, dt, total_time); - - // Handle interaction - if (mapper.isActionPressed(input, .interact_primary)) { - self.player.breakTargetBlock(self.world); - self.hand_renderer.swing(); + if (mapper.isActionPressed(input, .slot_1)) self.inventory.selectSlot(0); + if (mapper.isActionPressed(input, .slot_2)) self.inventory.selectSlot(1); + if (mapper.isActionPressed(input, .slot_3)) self.inventory.selectSlot(2); + if (mapper.isActionPressed(input, .slot_4)) self.inventory.selectSlot(3); + if (mapper.isActionPressed(input, .slot_5)) self.inventory.selectSlot(4); + if (mapper.isActionPressed(input, .slot_6)) self.inventory.selectSlot(5); + if (mapper.isActionPressed(input, .slot_7)) self.inventory.selectSlot(6); + if (mapper.isActionPressed(input, .slot_8)) self.inventory.selectSlot(7); + if (mapper.isActionPressed(input, .slot_9)) self.inventory.selectSlot(8); + const scroll_y = input.getScrollDelta().y; + if (scroll_y != 0) { + self.inventory.scrollSelection(@intFromFloat(scroll_y)); } - if (mapper.isActionPressed(input, .interact_secondary)) { - if (self.inventory.getSelectedBlock()) |block_type| { - self.player.placeBlock(self.world, block_type); + } + + if (self.map_controller.show_map) { + self.map_controller.update(input, mapper, &self.camera, dt, window, screen_w, screen_h, self.world_map.width); + } else if (!skip_world) { + if (!self.inventory_ui_state.visible) { + self.player.update(input, mapper, self.world, dt, total_time); + + // Handle interaction + if (mapper.isActionPressed(input, .interact_primary)) { + self.player.breakTargetBlock(self.world); self.hand_renderer.swing(); } + if (mapper.isActionPressed(input, .interact_secondary)) { + if (self.inventory.getSelectedBlock()) |block_type| { + self.player.placeBlock(self.world, block_type); + self.hand_renderer.swing(); + } + } } - } - self.hand_renderer.update(dt); - try self.hand_renderer.updateMesh(self.inventory, atlas); - } else if (!self.world.paused) { - self.world.pauseGeneration(); + self.hand_renderer.update(dt); + try self.hand_renderer.updateMesh(self.inventory, atlas); + } else if (!self.world.paused) { + self.world.pauseGeneration(); + } } if (!skip_world) {