From 3e2e39100a5e48157b59034d927c992bbdbea5cd Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Tue, 16 Dec 2025 11:56:15 -0500 Subject: [PATCH] Add zoom and panning via mouse --- src/sandbox_main.zig | 80 +++++++++++++++++++++++++++++++++++++++-- src/shaders/entity.vert | 14 ++++---- src/ssbo_renderer.zig | 15 +++++++- src/ui.zig | 17 ++++++--- 4 files changed, 112 insertions(+), 14 deletions(-) diff --git a/src/sandbox_main.zig b/src/sandbox_main.zig index 66c1aea..3908c42 100644 --- a/src/sandbox_main.zig +++ b/src/sandbox_main.zig @@ -31,6 +31,11 @@ const BENCH_RAMP_AMOUNT: usize = 50_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 +// zoom settings +const ZOOM_MIN: f32 = 1.0; +const ZOOM_MAX: f32 = 10.0; +const ZOOM_SPEED: f32 = 0.1; // multiplier per scroll tick + const BenchmarkLogger = struct { file: ?std.fs.File, last_logged_frame_ms: f32, @@ -264,6 +269,11 @@ pub fn main() !void { var rng = prng.random(); var paused = false; + + // camera state for zoom/pan + var zoom: f32 = 1.0; + var pan = @Vector(2, f32){ 0, 0 }; + var logger = BenchmarkLogger.init(); defer logger.deinit(); @@ -316,6 +326,7 @@ pub fn main() !void { } else { // manual controls handleInput(&entities, &rng, &paused); + handleCamera(&zoom, &pan); } // update @@ -333,7 +344,7 @@ pub fn main() !void { if (use_ssbo) { // SSBO instanced rendering path (12 bytes per entity) - ssbo_renderer.?.render(&entities); + ssbo_renderer.?.render(&entities, zoom, pan); } else if (use_instancing) { // GPU instancing path (64 bytes per entity) const xforms = transforms.?; @@ -384,7 +395,7 @@ pub fn main() !void { // 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); + ui.drawMetrics(&entities, update_time_us, render_time_us, paused, zoom, ui_font); ui.drawMemory(entities.count, ui_font); } @@ -456,3 +467,68 @@ fn handleInput(entities: *sandbox.Entities, rng: *std.Random, paused: *bool) voi ui.show_ui = !ui.show_ui; } } + +fn handleCamera(zoom: *f32, pan: *@Vector(2, f32)) void { + const wheel = rl.getMouseWheelMove(); + + if (wheel != 0) { + const mouse_pos = rl.getMousePosition(); + const old_zoom = zoom.*; + + // calculate new zoom + const zoom_factor = if (wheel > 0) (1.0 + ZOOM_SPEED) else (1.0 / (1.0 + ZOOM_SPEED)); + var new_zoom = old_zoom * zoom_factor; + new_zoom = std.math.clamp(new_zoom, ZOOM_MIN, ZOOM_MAX); + + if (new_zoom != old_zoom) { + // zoom toward mouse cursor: + // keep the world point under the cursor stationary + // world_pos = (screen_pos / old_zoom) + old_pan + // new_pan = world_pos - (screen_pos / new_zoom) + const world_x = (mouse_pos.x / old_zoom) + pan.*[0]; + const world_y = (mouse_pos.y / old_zoom) + pan.*[1]; + pan.*[0] = world_x - (mouse_pos.x / new_zoom); + pan.*[1] = world_y - (mouse_pos.y / new_zoom); + zoom.* = new_zoom; + + // clamp pan to bounds + clampPan(pan, zoom.*); + } + } + + // pan with any mouse button drag (only when zoomed in) + if (zoom.* > 1.0) { + const any_button = rl.isMouseButtonDown(.left) or + rl.isMouseButtonDown(.right) or + rl.isMouseButtonDown(.middle); + if (any_button) { + const delta = rl.getMouseDelta(); + // drag down = view down, drag right = view right + pan.*[0] -= delta.x / zoom.*; + pan.*[1] += delta.y / zoom.*; + clampPan(pan, zoom.*); + } + } + + // reset on Return + if (rl.isKeyPressed(.enter)) { + zoom.* = 1.0; + pan.* = @Vector(2, f32){ 0, 0 }; + } +} + +fn clampPan(pan: *@Vector(2, f32), zoom: f32) void { + // when zoomed in, limit pan so viewport stays in simulation bounds + // visible area = screen_size / zoom + // max pan = world_size - visible_area + const screen_w: f32 = @floatFromInt(SCREEN_WIDTH); + const screen_h: f32 = @floatFromInt(SCREEN_HEIGHT); + const visible_w = screen_w / zoom; + const visible_h = screen_h / zoom; + + const max_pan_x = @max(0, screen_w - visible_w); + const max_pan_y = @max(0, screen_h - visible_h); + + pan.*[0] = std.math.clamp(pan.*[0], 0, max_pan_x); + pan.*[1] = std.math.clamp(pan.*[1], 0, max_pan_y); +} diff --git a/src/shaders/entity.vert b/src/shaders/entity.vert index 655377c..590882d 100644 --- a/src/shaders/entity.vert +++ b/src/shaders/entity.vert @@ -17,6 +17,8 @@ layout(std430, binding = 0) readonly buffer EntityData { // screen size for NDC conversion uniform vec2 screenSize; +uniform float zoom; +uniform vec2 pan; out vec2 fragTexCoord; out vec3 fragColor; @@ -25,13 +27,13 @@ void main() { // get entity data from SSBO Entity e = entities[gl_InstanceID]; - // convert entity position to NDC - // entity coords are in screen pixels, convert to [-1, 1] - float ndcX = (e.x / screenSize.x) * 2.0 - 1.0; - float ndcY = (e.y / screenSize.y) * 2.0 - 1.0; + // apply pan offset and zoom to convert to NDC + // pan is in screen pixels, zoom scales the view + float ndcX = ((e.x - pan.x) * zoom / screenSize.x) * 2.0 - 1.0; + float ndcY = ((e.y - pan.y) * zoom / screenSize.y) * 2.0 - 1.0; - // quad size in NDC (16 pixels) - float quadSizeNdc = 16.0 / screenSize.x; + // quad size in NDC (16 pixels, scaled by zoom) + float quadSizeNdc = (16.0 * zoom) / screenSize.x; // offset by quad corner position gl_Position = vec4(ndcX + position.x * quadSizeNdc, diff --git a/src/ssbo_renderer.zig b/src/ssbo_renderer.zig index e4b5225..7a75bb7 100644 --- a/src/ssbo_renderer.zig +++ b/src/ssbo_renderer.zig @@ -19,6 +19,8 @@ pub const SsboRenderer = struct { ssbo_id: u32, screen_size_loc: i32, circle_texture_loc: i32, + zoom_loc: i32, + pan_loc: i32, circle_texture_id: u32, gpu_buffer: []sandbox.GpuEntity, @@ -53,6 +55,8 @@ pub const SsboRenderer = struct { // get uniform locations const screen_size_loc = rl.gl.rlGetLocationUniform(shader_id, "screenSize"); const circle_texture_loc = rl.gl.rlGetLocationUniform(shader_id, "circleTexture"); + const zoom_loc = rl.gl.rlGetLocationUniform(shader_id, "zoom"); + const pan_loc = rl.gl.rlGetLocationUniform(shader_id, "pan"); if (screen_size_loc < 0) { std.debug.print("ssbo: warning - screenSize uniform not found\n", .{}); @@ -116,6 +120,8 @@ pub const SsboRenderer = struct { .ssbo_id = ssbo_id, .screen_size_loc = screen_size_loc, .circle_texture_loc = circle_texture_loc, + .zoom_loc = zoom_loc, + .pan_loc = pan_loc, .circle_texture_id = circle_texture.id, .gpu_buffer = gpu_buffer, }; @@ -129,7 +135,7 @@ pub const SsboRenderer = struct { std.heap.page_allocator.free(self.gpu_buffer); } - pub fn render(self: *SsboRenderer, entities: *const sandbox.Entities) void { + pub fn render(self: *SsboRenderer, entities: *const sandbox.Entities, zoom: f32, pan: @Vector(2, f32)) void { if (entities.count == 0) return; // flush raylib's internal render batch before our custom GL calls @@ -155,6 +161,13 @@ pub const SsboRenderer = struct { const screen_size = [2]f32{ @floatFromInt(SCREEN_WIDTH), @floatFromInt(SCREEN_HEIGHT) }; rl.gl.rlSetUniform(self.screen_size_loc, &screen_size, @intFromEnum(rl.gl.rlShaderUniformDataType.rl_shader_uniform_vec2), 1); + // set zoom uniform + rl.gl.rlSetUniform(self.zoom_loc, &zoom, @intFromEnum(rl.gl.rlShaderUniformDataType.rl_shader_uniform_float), 1); + + // set pan uniform + const pan_arr = [2]f32{ pan[0], pan[1] }; + rl.gl.rlSetUniform(self.pan_loc, &pan_arr, @intFromEnum(rl.gl.rlShaderUniformDataType.rl_shader_uniform_vec2), 1); + // bind texture rl.gl.rlActiveTextureSlot(0); rl.gl.rlEnableTexture(self.circle_texture_id); diff --git a/src/ui.zig b/src/ui.zig index 8ea4981..0a8115c 100644 --- a/src/ui.zig +++ b/src/ui.zig @@ -31,7 +31,7 @@ pub var show_ui: bool = true; // drawing functions // ============================================================================= -pub fn drawMetrics(entities: *const sandbox.Entities, update_us: i64, render_us: i64, paused: bool, font: rl.Font) void { +pub fn drawMetrics(entities: *const sandbox.Entities, update_us: i64, render_us: i64, paused: bool, zoom: f32, font: rl.Font) void { if (!show_ui) return; var buf: [256]u8 = undefined; @@ -47,7 +47,7 @@ pub fn drawMetrics(entities: *const sandbox.Entities, update_us: i64, render_us: // metrics box (below fps) const metrics_y: i32 = 5 + fps_box_height + 5; var y: f32 = @as(f32, @floatFromInt(metrics_y)) + box_padding; - const bg_height: i32 = if (paused) 130 else 100; + const bg_height: i32 = if (paused) 150 else 120; rl.drawRectangle(5, metrics_y, 180, bg_height, box_bg); // entity count @@ -72,6 +72,11 @@ pub fn drawMetrics(entities: *const sandbox.Entities, update_us: i64, render_us: rl.drawTextEx(font, render_text, .{ .x = padding, .y = y }, font_size, 0, text_color); y += line_height; + // zoom level + const zoom_text = std.fmt.bufPrintZ(&buf, "zoom: {d:.1}x", .{zoom}) catch "?"; + rl.drawTextEx(font, zoom_text, .{ .x = padding, .y = y }, font_size, 0, if (zoom > 1.0) highlight_color else text_color); + y += line_height; + // paused indicator if (paused) { y += line_height; @@ -118,7 +123,7 @@ pub fn drawMemory(entity_count: usize, font: rl.Font) void { } fn drawControls(font: rl.Font, metrics_bottom: i32) void { - const ctrl_box_height: i32 = @intFromFloat(small_line_height * 5 + box_padding * 2); + const ctrl_box_height: i32 = @intFromFloat(small_line_height * 7 + box_padding * 2); const ctrl_box_y: i32 = metrics_bottom + 5; rl.drawRectangle(5, ctrl_box_y, 175, ctrl_box_height, box_bg); @@ -127,8 +132,10 @@ fn drawControls(font: rl.Font, metrics_bottom: i32) void { const controls = [_][]const u8{ "+/-: 10k entities", "shift +/-: 50k", - "space: pause", - "r: reset", + "scroll: zoom", + "drag: pan (zoomed)", + "space: pause, r: reset", + "enter: reset zoom", "tab: toggle ui", };