413 lines
14 KiB
Zig
413 lines
14 KiB
Zig
// sandbox stress test entry point
|
|
// measures entity count ceiling on weak hardware
|
|
|
|
const std = @import("std");
|
|
const rl = @import("raylib");
|
|
const sandbox = @import("sandbox.zig");
|
|
const ui = @import("ui.zig");
|
|
|
|
const SCREEN_WIDTH = sandbox.SCREEN_WIDTH;
|
|
const SCREEN_HEIGHT = sandbox.SCREEN_HEIGHT;
|
|
|
|
// colors
|
|
const BG_COLOR = rl.Color{ .r = 10, .g = 10, .b = 18, .a = 255 };
|
|
const CYAN = rl.Color{ .r = 0, .g = 255, .b = 255, .a = 255 };
|
|
|
|
// entity rendering
|
|
const ENTITY_RADIUS: f32 = 4.0;
|
|
const TEXTURE_SIZE: i32 = 16; // must be >= 2 * radius
|
|
const MESH_SIZE: f32 = @floatFromInt(TEXTURE_SIZE); // match texture size
|
|
|
|
// logging thresholds
|
|
const TARGET_FRAME_MS: f32 = 16.7; // 60fps
|
|
const THRESHOLD_MARGIN: f32 = 2.0; // hysteresis margin to avoid bounce
|
|
const JUMP_THRESHOLD_MS: f32 = 5.0; // log if frame time jumps by this much
|
|
const HEARTBEAT_INTERVAL: f32 = 10.0; // seconds between periodic logs
|
|
|
|
// auto-benchmark settings
|
|
const BENCH_RAMP_INTERVAL: f32 = 2.0; // seconds between entity ramps
|
|
const BENCH_RAMP_AMOUNT: usize = 10_000; // entities added per ramp
|
|
const BENCH_EXIT_THRESHOLD_MS: f32 = 25.0; // exit when frame time exceeds this
|
|
const BENCH_EXIT_SUSTAIN: f32 = 1.0; // must stay above threshold for this long
|
|
|
|
const BenchmarkLogger = struct {
|
|
file: ?std.fs.File,
|
|
last_logged_frame_ms: f32,
|
|
was_above_target: bool,
|
|
last_heartbeat: f32,
|
|
start_time: i64,
|
|
|
|
fn init() BenchmarkLogger {
|
|
// create log in project root (where zig build runs from)
|
|
const file = std.fs.cwd().createFile("benchmark.log", .{}) catch |err| blk: {
|
|
std.debug.print("failed to create benchmark.log: {}\n", .{err});
|
|
break :blk null;
|
|
};
|
|
if (file) |f| {
|
|
const header = "# lofivor sandbox benchmark\n# time entities frame_ms update_ms render_ms note\n";
|
|
f.writeAll(header) catch {};
|
|
std.debug.print("logging to benchmark.log\n", .{});
|
|
}
|
|
return .{
|
|
.file = file,
|
|
.last_logged_frame_ms = 0,
|
|
.was_above_target = false,
|
|
.last_heartbeat = 0,
|
|
.start_time = std.time.timestamp(),
|
|
};
|
|
}
|
|
|
|
fn deinit(self: *BenchmarkLogger) void {
|
|
if (self.file) |f| f.close();
|
|
}
|
|
|
|
fn log(self: *BenchmarkLogger, elapsed: f32, entity_count: usize, frame_ms: f32, update_ms: f32, render_ms: f32) void {
|
|
const f = self.file orelse return;
|
|
|
|
// hysteresis: need to cross threshold + margin to flip state
|
|
var crossed_threshold = false;
|
|
var now_above = self.was_above_target;
|
|
if (self.was_above_target) {
|
|
// need to drop below target to flip back
|
|
if (frame_ms < TARGET_FRAME_MS) {
|
|
now_above = false;
|
|
crossed_threshold = true;
|
|
}
|
|
} else {
|
|
// need to exceed target + margin to flip
|
|
if (frame_ms > TARGET_FRAME_MS + THRESHOLD_MARGIN) {
|
|
now_above = true;
|
|
crossed_threshold = true;
|
|
}
|
|
}
|
|
|
|
const big_jump = (frame_ms - self.last_logged_frame_ms) >= JUMP_THRESHOLD_MS;
|
|
const heartbeat_due = (elapsed - self.last_heartbeat) >= HEARTBEAT_INTERVAL;
|
|
|
|
if (!crossed_threshold and !big_jump and !heartbeat_due) return;
|
|
|
|
const fps = if (frame_ms > 0) 1000.0 / frame_ms else 0;
|
|
|
|
// determine note - show ! when below target fps
|
|
var note_buf: [16]u8 = undefined;
|
|
const note = if (now_above)
|
|
std.fmt.bufPrint(¬e_buf, "[!{d:.0}fps]", .{fps}) catch ""
|
|
else
|
|
std.fmt.bufPrint(¬e_buf, "[{d:.0}fps]", .{fps}) catch "";
|
|
|
|
var buf: [256]u8 = undefined;
|
|
const line = std.fmt.bufPrint(&buf, "[{d:.1}s] entities={d} frame={d:.1}ms update={d:.1}ms render={d:.1}ms {s}\n", .{
|
|
elapsed,
|
|
entity_count,
|
|
frame_ms,
|
|
update_ms,
|
|
render_ms,
|
|
note,
|
|
}) catch return;
|
|
|
|
f.writeAll(line) catch {};
|
|
|
|
self.last_logged_frame_ms = frame_ms;
|
|
self.was_above_target = now_above;
|
|
if (heartbeat_due) self.last_heartbeat = elapsed;
|
|
}
|
|
};
|
|
|
|
fn createCircleTexture() ?rl.Texture2D {
|
|
// create a render texture to draw circle into
|
|
const target = rl.loadRenderTexture(TEXTURE_SIZE, TEXTURE_SIZE) catch return null;
|
|
|
|
rl.beginTextureMode(target);
|
|
rl.clearBackground(rl.Color{ .r = 0, .g = 0, .b = 0, .a = 0 }); // transparent
|
|
rl.drawCircle(
|
|
@divTrunc(TEXTURE_SIZE, 2),
|
|
@divTrunc(TEXTURE_SIZE, 2),
|
|
ENTITY_RADIUS,
|
|
rl.Color{ .r = 255, .g = 255, .b = 255, .a = 255 }, // white, tinted per-entity
|
|
);
|
|
rl.endTextureMode();
|
|
|
|
return target.texture;
|
|
}
|
|
|
|
fn createOrthoCamera() rl.Camera3D {
|
|
// orthographic camera looking down -Y axis at XZ plane
|
|
// positioned to match 2D screen coordinates
|
|
const hw = @as(f32, @floatFromInt(SCREEN_WIDTH)) / 2.0;
|
|
const hh = @as(f32, @floatFromInt(SCREEN_HEIGHT)) / 2.0;
|
|
return .{
|
|
.position = .{ .x = hw, .y = 1000, .z = hh },
|
|
.target = .{ .x = hw, .y = 0, .z = hh },
|
|
.up = .{ .x = 0, .y = 0, .z = -1 }, // -Z is up to match screen Y
|
|
.fovy = @floatFromInt(SCREEN_HEIGHT), // ortho uses fovy as height
|
|
.projection = .orthographic,
|
|
};
|
|
}
|
|
|
|
fn createInstanceMaterial(texture: rl.Texture2D) ?rl.Material {
|
|
var material = rl.loadMaterialDefault() catch return null;
|
|
rl.setMaterialTexture(&material, rl.MATERIAL_MAP_DIFFUSE, texture);
|
|
return material;
|
|
}
|
|
|
|
pub fn main() !void {
|
|
// parse args
|
|
var bench_mode = false;
|
|
var use_instancing = false;
|
|
var args = try std.process.argsWithAllocator(std.heap.page_allocator);
|
|
defer args.deinit();
|
|
_ = args.skip(); // skip program name
|
|
while (args.next()) |arg| {
|
|
if (std.mem.eql(u8, arg, "--bench")) {
|
|
bench_mode = true;
|
|
} else if (std.mem.eql(u8, arg, "--gpu")) {
|
|
use_instancing = true;
|
|
}
|
|
}
|
|
|
|
rl.initWindow(@intCast(SCREEN_WIDTH), @intCast(SCREEN_HEIGHT), "lofivor sandbox");
|
|
defer rl.closeWindow();
|
|
rl.setTargetFPS(60);
|
|
|
|
// use larger batch buffer: 16384 elements vs default 8192
|
|
// fewer flushes = less driver overhead per frame
|
|
const numElements: i32 = 8192 * 4; // quads = 4 verts
|
|
var custom_batch = rl.gl.rlLoadRenderBatch(1, numElements);
|
|
rl.gl.rlSetRenderBatchActive(&custom_batch);
|
|
defer {
|
|
rl.gl.rlSetRenderBatchActive(null); // restore default
|
|
rl.gl.rlUnloadRenderBatch(custom_batch);
|
|
}
|
|
|
|
// create circle texture for batched rendering
|
|
const circle_texture = createCircleTexture() orelse {
|
|
std.debug.print("failed to create circle texture\n", .{});
|
|
return;
|
|
};
|
|
defer rl.unloadTexture(circle_texture);
|
|
|
|
// GPU instancing setup (only if --gpu flag)
|
|
var quad_mesh: ?rl.Mesh = null;
|
|
var instance_material: ?rl.Material = null;
|
|
var ortho_camera: rl.Camera3D = undefined;
|
|
|
|
// static buffer for transforms - allocated once, reused each frame
|
|
var transforms: [sandbox.MAX_ENTITIES]rl.Matrix = undefined;
|
|
|
|
if (use_instancing) {
|
|
// create quad mesh (XZ plane, will view from above)
|
|
quad_mesh = rl.genMeshPlane(MESH_SIZE, MESH_SIZE, 1, 1);
|
|
rl.uploadMesh(&quad_mesh.?, false); // upload to GPU
|
|
|
|
// material with circle texture
|
|
instance_material = createInstanceMaterial(circle_texture) orelse {
|
|
std.debug.print("failed to create instance material\n", .{});
|
|
return;
|
|
};
|
|
|
|
// orthographic camera for 2D-like rendering
|
|
ortho_camera = createOrthoCamera();
|
|
|
|
std.debug.print("GPU instancing mode enabled\n", .{});
|
|
}
|
|
|
|
defer {
|
|
if (quad_mesh) |*m| rl.unloadMesh(m.*);
|
|
if (instance_material) |mat| mat.unload();
|
|
}
|
|
|
|
// load UI font (embedded)
|
|
const font_data = @embedFile("verdanab.ttf");
|
|
const ui_font = rl.loadFontFromMemory(".ttf", font_data, 32, null) catch {
|
|
std.debug.print("failed to load embedded font\n", .{});
|
|
return;
|
|
};
|
|
defer rl.unloadFont(ui_font);
|
|
|
|
var entities = sandbox.Entities.init();
|
|
var prng = std.Random.DefaultPrng.init(@intCast(std.time.timestamp()));
|
|
var rng = prng.random();
|
|
|
|
var paused = false;
|
|
var logger = BenchmarkLogger.init();
|
|
defer logger.deinit();
|
|
|
|
// timing
|
|
var update_time_us: i64 = 0;
|
|
var render_time_us: i64 = 0;
|
|
var elapsed: f32 = 0;
|
|
|
|
// auto-benchmark state
|
|
var last_ramp_time: f32 = 0;
|
|
var above_threshold_time: f32 = 0;
|
|
var smoothed_frame_ms: f32 = 16.7;
|
|
|
|
if (bench_mode) {
|
|
std.debug.print("auto-benchmark mode: ramping to failure or 1M entities\n", .{});
|
|
}
|
|
|
|
while (!rl.windowShouldClose()) {
|
|
const dt = rl.getFrameTime();
|
|
elapsed += dt;
|
|
const frame_ms = dt * 1000.0;
|
|
|
|
// smooth frame time for stable exit detection
|
|
smoothed_frame_ms = smoothed_frame_ms * 0.9 + frame_ms * 0.1;
|
|
|
|
// auto-benchmark logic
|
|
if (bench_mode) {
|
|
// check exit condition: sustained poor performance
|
|
if (smoothed_frame_ms > BENCH_EXIT_THRESHOLD_MS) {
|
|
above_threshold_time += dt;
|
|
if (above_threshold_time >= BENCH_EXIT_SUSTAIN) {
|
|
std.debug.print("benchmark complete: {d} entities @ {d:.1}ms avg frame\n", .{ entities.count, smoothed_frame_ms });
|
|
break;
|
|
}
|
|
} else {
|
|
above_threshold_time = 0;
|
|
}
|
|
|
|
// check exit: hit max entities
|
|
if (entities.count >= sandbox.MAX_ENTITIES) {
|
|
std.debug.print("benchmark complete: hit max {d} entities\n", .{sandbox.MAX_ENTITIES});
|
|
break;
|
|
}
|
|
|
|
// ramp entities
|
|
if (elapsed - last_ramp_time >= BENCH_RAMP_INTERVAL) {
|
|
for (0..BENCH_RAMP_AMOUNT) |_| entities.add(&rng);
|
|
last_ramp_time = elapsed;
|
|
}
|
|
} else {
|
|
// manual controls
|
|
handleInput(&entities, &rng, &paused);
|
|
}
|
|
|
|
// update
|
|
if (!paused) {
|
|
const update_start = std.time.microTimestamp();
|
|
sandbox.update(&entities, &rng);
|
|
update_time_us = std.time.microTimestamp() - update_start;
|
|
}
|
|
|
|
// render
|
|
const render_start = std.time.microTimestamp();
|
|
|
|
rl.beginDrawing();
|
|
rl.clearBackground(BG_COLOR);
|
|
|
|
if (use_instancing) {
|
|
// GPU instancing path
|
|
// fill transforms array with entity positions
|
|
for (entities.items[0..entities.count], 0..) |entity, i| {
|
|
// entity (x, y) maps to 3D (x, 0, y) on XZ plane
|
|
transforms[i] = rl.Matrix.translate(entity.x, 0, entity.y);
|
|
}
|
|
|
|
// draw all entities with single instanced call
|
|
ortho_camera.begin();
|
|
rl.drawMeshInstanced(quad_mesh.?, instance_material.?, transforms[0..entities.count]);
|
|
ortho_camera.end();
|
|
} else {
|
|
// rlgl quad batching path (original)
|
|
const size = @as(f32, @floatFromInt(TEXTURE_SIZE));
|
|
const half = size / 2.0;
|
|
|
|
rl.gl.rlSetTexture(circle_texture.id);
|
|
rl.gl.rlBegin(rl.gl.rl_quads);
|
|
|
|
for (entities.items[0..entities.count]) |entity| {
|
|
// extract RGB from entity color (0xRRGGBB)
|
|
const r: u8 = @truncate(entity.color >> 16);
|
|
const g: u8 = @truncate(entity.color >> 8);
|
|
const b: u8 = @truncate(entity.color);
|
|
rl.gl.rlColor4ub(r, g, b, 255);
|
|
|
|
const x1 = entity.x - half;
|
|
const y1 = entity.y - half;
|
|
const x2 = entity.x + half;
|
|
const y2 = entity.y + half;
|
|
|
|
// quad vertices: bottom-left, bottom-right, top-right, top-left
|
|
rl.gl.rlTexCoord2f(0, 0);
|
|
rl.gl.rlVertex2f(x1, y2);
|
|
rl.gl.rlTexCoord2f(1, 0);
|
|
rl.gl.rlVertex2f(x2, y2);
|
|
rl.gl.rlTexCoord2f(1, 1);
|
|
rl.gl.rlVertex2f(x2, y1);
|
|
rl.gl.rlTexCoord2f(0, 1);
|
|
rl.gl.rlVertex2f(x1, y1);
|
|
}
|
|
|
|
rl.gl.rlEnd();
|
|
rl.gl.rlSetTexture(0);
|
|
}
|
|
|
|
// metrics overlay (skip in bench mode for cleaner headless run)
|
|
if (!bench_mode) {
|
|
ui.drawMetrics(&entities, update_time_us, render_time_us, paused, ui_font);
|
|
}
|
|
|
|
rl.endDrawing();
|
|
|
|
render_time_us = std.time.microTimestamp() - render_start;
|
|
|
|
// smart logging
|
|
const update_ms = @as(f32, @floatFromInt(update_time_us)) / 1000.0;
|
|
const render_ms = @as(f32, @floatFromInt(render_time_us)) / 1000.0;
|
|
logger.log(elapsed, entities.count, frame_ms, update_ms, render_ms);
|
|
}
|
|
}
|
|
|
|
const REPEAT_DELAY: f32 = 0.4; // initial delay before repeat
|
|
const REPEAT_RATE: f32 = 0.05; // repeat interval
|
|
|
|
var add_timer: f32 = 0;
|
|
var sub_timer: f32 = 0;
|
|
|
|
fn handleInput(entities: *sandbox.Entities, rng: *std.Random, paused: *bool) void {
|
|
const dt = rl.getFrameTime();
|
|
const shift = rl.isKeyDown(.left_shift) or rl.isKeyDown(.right_shift);
|
|
const add_count: usize = if (shift) 10000 else 1000;
|
|
|
|
const add_held = rl.isKeyDown(.equal) or rl.isKeyDown(.kp_add);
|
|
const sub_held = rl.isKeyDown(.minus) or rl.isKeyDown(.kp_subtract);
|
|
|
|
// add entities: = or +
|
|
if (rl.isKeyPressed(.equal) or rl.isKeyPressed(.kp_add)) {
|
|
for (0..add_count) |_| entities.add(rng);
|
|
add_timer = REPEAT_DELAY;
|
|
} else if (add_held) {
|
|
add_timer -= dt;
|
|
if (add_timer <= 0) {
|
|
for (0..add_count) |_| entities.add(rng);
|
|
add_timer = REPEAT_RATE;
|
|
}
|
|
} else {
|
|
add_timer = 0;
|
|
}
|
|
|
|
// remove entities: - or _
|
|
if (rl.isKeyPressed(.minus) or rl.isKeyPressed(.kp_subtract)) {
|
|
entities.remove(add_count);
|
|
sub_timer = REPEAT_DELAY;
|
|
} else if (sub_held) {
|
|
sub_timer -= dt;
|
|
if (sub_timer <= 0) {
|
|
entities.remove(add_count);
|
|
sub_timer = REPEAT_RATE;
|
|
}
|
|
} else {
|
|
sub_timer = 0;
|
|
}
|
|
|
|
// reset: r
|
|
if (rl.isKeyPressed(.r)) {
|
|
entities.reset();
|
|
}
|
|
|
|
// pause: space
|
|
if (rl.isKeyPressed(.space)) {
|
|
paused.* = !paused.*;
|
|
}
|
|
}
|