From a2fc2befd16f470e9997f4dfd79307fda030ef8c Mon Sep 17 00:00:00 2001 From: Rokas Puzonas Date: Tue, 8 Apr 2025 21:36:20 +0300 Subject: [PATCH] add cache for min/max calculations when drawing graphs --- src/app.zig | 33 +++++++- src/components/view.zig | 2 + src/graph.zig | 153 +++++++++++++++++++++++++++++++++--- src/main.zig | 4 + src/screens/main_screen.zig | 2 +- 5 files changed, 181 insertions(+), 13 deletions(-) diff --git a/src/app.zig b/src/app.zig index ec0a70b..0d44f07 100644 --- a/src/app.zig +++ b/src/app.zig @@ -173,9 +173,11 @@ pub const Channel = struct { device: Device = .{}, allowed_sample_values: ?RangeF64 = null, allowed_sample_rates: ?RangeF64 = null, + // TODO: Use a linked list, so the whole list wouldn't need to be reallocated when appending collected samples collected_samples: std.ArrayListUnmanaged(f64) = .{}, write_pattern: std.ArrayListUnmanaged(f64) = .{}, output_task: ?NIDaq.Task = null, + graph_min_max_cache: Graph.MinMaxCache = .{}, pub fn deinit(self: *Channel, allocator: Allocator) void { self.clear(allocator); @@ -186,6 +188,8 @@ pub const Channel = struct { task.clear(); self.output_task = null; } + + self.graph_min_max_cache.deinit(allocator); } pub fn clear(self: *Channel, allocator: Allocator) void { @@ -221,11 +225,13 @@ pub const File = struct { // Runtime samples: ?[]f64 = null, + graph_min_max_cache: Graph.MinMaxCache = .{}, min_sample: f64 = 0, max_sample: f64 = 0, pub fn deinit(self: *File, allocator: Allocator) void { self.clear(allocator); + self.graph_min_max_cache.deinit(allocator); allocator.free(self.path); } @@ -253,7 +259,7 @@ pub const View = struct { sync_controls: bool = false, // Runtime - graph_cache: Graph.Cache = .{}, + graph_cache: Graph.RenderCache = .{}, available_x_range: RangeF64 = RangeF64.init(0, 0), available_y_range: RangeF64 = RangeF64.init(0, 0), unit: ?NIDaq.Unit = .Voltage, @@ -756,6 +762,14 @@ pub fn tick(self: *App) !void { self.collection_samples_mutex.lock(); defer self.collection_samples_mutex.unlock(); + // Update channel min max caches + { + var channel_iter = self.project.channels.iterator(); + while (channel_iter.next()) |channel| { + try channel.graph_min_max_cache.updateLast(self.allocator, channel.collected_samples.items); + } + } + { var view_iter = self.project.views.idIterator(); while (view_iter.next()) |id| { @@ -1108,6 +1122,8 @@ pub fn loadFile(self: *App, id: Id) !void { file.min_sample = @min(file.min_sample, sample); file.max_sample = @max(file.max_sample, sample); } + + try file.graph_min_max_cache.updateAll(self.allocator, samples); } } @@ -1246,6 +1262,21 @@ pub fn getViewSamples(self: *App, id: Id) []const f64 { return result; } +pub fn getViewMinMaxCache(self: *App, id: Id) Graph.MinMaxCache { + const view = self.getView(id).?; + + switch (view.reference) { + .channel => |channel_id| { + const channel = self.getChannel(channel_id).?; + return channel.graph_min_max_cache; + }, + .file => |file_id| { + const file = self.getFile(file_id).?; + return file.graph_min_max_cache; + } + } +} + fn refreshViewAvailableXYRanges(self: *App, id: Id) void { const view = self.getView(id) orelse return; diff --git a/src/components/view.zig b/src/components/view.zig index 1ce92b3..6d19ea9 100644 --- a/src/components/view.zig +++ b/src/components/view.zig @@ -100,6 +100,8 @@ fn showGraph(ctx: Context, view_id: Id) *UI.Box { ctx.view_controls.pushViewMove(view_id, view.available_x_range, view.available_y_range); } + view.graph_cache.min_max_cache = app.getViewMinMaxCache(view_id); + Graph.drawCached(&view.graph_cache, graph_box.persistent.size, view_opts.*, samples); if (view.graph_cache.texture) |texture| { graph_box.texture = texture.texture; diff --git a/src/graph.zig b/src/graph.zig index 83df29c..b706da6 100644 --- a/src/graph.zig +++ b/src/graph.zig @@ -44,16 +44,90 @@ pub const ViewOptions = struct { } }; -pub const Cache = struct { +pub const MinMaxCache = struct { + const MinMaxPair = struct { + min: f64, + max: f64 + }; + + const chunk_size = 256; + + min_max_pairs: std.ArrayListUnmanaged(MinMaxPair) = .{}, + sample_count: usize = 0, + + pub fn deinit(self: *MinMaxCache, allocator: std.mem.Allocator) void { + self.min_max_pairs.clearAndFree(allocator); + self.sample_count = 0; + } + + fn getMinMaxPair(chunk: []const f64) MinMaxPair { + assert(chunk.len > 0); + + var min_sample = chunk[0]; + var max_sample = chunk[0]; + for (chunk) |sample| { + min_sample = @min(min_sample, sample); + max_sample = @max(max_sample, sample); + } + return MinMaxPair{ + .min = min_sample, + .max = max_sample + }; + } + + pub fn updateAll(self: *MinMaxCache, allocator: std.mem.Allocator, samples: []const f64) !void { + self.min_max_pairs.clearRetainingCapacity(); + self.sample_count = 0; + + if (samples.len == 0) return; + + var iter = std.mem.window(f64, samples, chunk_size, chunk_size); + while (iter.next()) |chunk| { + try self.min_max_pairs.append(allocator, getMinMaxPair(chunk)); + } + + self.sample_count = samples.len; + } + + pub fn updateLast(self: *MinMaxCache, allocator: std.mem.Allocator, samples: []const f64) !void { + if (self.sample_count > samples.len) { + try self.updateAll(allocator, samples); + } + if (self.sample_count == samples.len) { + return; + } + + const from_chunk = @divFloor(self.sample_count, chunk_size); + const to_chunk = @divFloor(samples.len - 1, chunk_size); + + for (from_chunk..(to_chunk+1)) |i| { + const chunk = samples[ + (chunk_size*i)..(@min(chunk_size*(i+1), samples.len)) + ]; + const min_max_pair = getMinMaxPair(chunk); + + if (i <= self.min_max_pairs.items.len) { + try self.min_max_pairs.append(allocator, min_max_pair); + } else { + self.min_max_pairs.items[i] = min_max_pair; + } + } + + self.sample_count = samples.len; + } +}; + +pub const RenderCache = struct { const Key = struct { options: ViewOptions, drawn_x_range: RangeF64 }; + min_max_cache: ?MinMaxCache = null, texture: ?rl.RenderTexture2D = null, key: ?Key = null, - pub fn clear(self: *Cache) void { + pub fn clear(self: *RenderCache) void { if (self.texture) |texture| { texture.unload(); self.texture = null; @@ -61,11 +135,11 @@ pub const Cache = struct { self.key = null; } - pub fn invalidate(self: *Cache) void { + pub fn invalidate(self: *RenderCache) void { self.key = null; } - pub fn draw(self: Cache, rect: rl.Rectangle) void { + pub fn draw(self: RenderCache, rect: rl.Rectangle) void { if (self.texture) |texture| { const source = rl.Rectangle{ .x = 0, @@ -177,7 +251,60 @@ fn drawSamplesApproximate(draw_rect: rl.Rectangle, options: ViewOptions, samples } } -fn drawSamples(draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f64) void { +fn drawSamplesMinMax(draw_rect: rl.Rectangle, options: ViewOptions, min_max_cache: MinMaxCache) void { + const draw_x_range, const draw_y_range = RangeF64.initRect(draw_rect); + + const i_range = options.x_range.intersectPositive( + RangeF64.init(0, @max(@as(f64, @floatFromInt(min_max_cache.sample_count)) - 1, 0)) + ); + if (i_range.lower > i_range.upper) { + return; + } + + const samples_per_column = options.x_range.size() / draw_x_range.size(); + assert(samples_per_column >= 1); + + var i = i_range.lower; + while (i < i_range.upper - samples_per_column) : (i += samples_per_column) { + var column_start: usize = @intFromFloat(i); + var column_end: usize = @intFromFloat(i + samples_per_column); + + column_start = @divFloor(column_start, MinMaxCache.chunk_size); + column_end = @divFloor(column_end, MinMaxCache.chunk_size); + + const min_max_pairs = min_max_cache.min_max_pairs.items[column_start..column_end]; + if (min_max_pairs.len == 0) continue; + + var column_min = min_max_pairs[0].min; + var column_max = min_max_pairs[0].max; + + for (min_max_pairs) |min_max_pair| { + column_min = @min(column_min, min_max_pair.min); + column_max = @max(column_max, min_max_pair.max); + } + + const x = options.x_range.remapTo(draw_x_range, i); + const y_min = options.y_range.remapTo(draw_y_range, column_min); + const y_max = options.y_range.remapTo(draw_y_range, column_max); + + if (@abs(y_max - y_min) < 1) { + const avg = (y_min + y_max) / 2; + rl.drawLineV( + .{ .x = @floatCast(x), .y = @floatCast(avg) }, + .{ .x = @floatCast(x), .y = @floatCast(avg+1) }, + options.color + ); + } else { + rl.drawLineV( + .{ .x = @floatCast(x), .y = @floatCast(y_min) }, + .{ .x = @floatCast(x), .y = @floatCast(y_max) }, + options.color + ); + } + } +} + +fn drawSamples(draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f64, min_max_level: ?MinMaxCache) void { const x_range = options.x_range; if (x_range.lower >= @as(f64, @floatFromInt(samples.len))) return; @@ -185,13 +312,17 @@ fn drawSamples(draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f const samples_per_column = x_range.size() / draw_rect.width; if (samples_per_column >= 2) { - drawSamplesApproximate(draw_rect, options, samples); + if (min_max_level != null and samples_per_column > 2*MinMaxCache.chunk_size) { + drawSamplesMinMax(draw_rect, options, min_max_level.?); + } else { + drawSamplesApproximate(draw_rect, options, samples); + } } else { drawSamplesExact(draw_rect, options, samples); } } -pub fn drawCached(cache: *Cache, render_size: Vec2, options: ViewOptions, samples: []const f64) void { +pub fn drawCached(cache: *RenderCache, render_size: Vec2, options: ViewOptions, samples: []const f64) void { const render_width: i32 = @intFromFloat(@ceil(render_size.x)); const render_height: i32 = @intFromFloat(@ceil(render_size.y)); @@ -218,7 +349,7 @@ pub fn drawCached(cache: *Cache, render_size: Vec2, options: ViewOptions, sample const render_texture = cache.texture.?; - const cache_key = Cache.Key{ + const cache_key = RenderCache.Key{ .options = options, .drawn_x_range = RangeF64.init(0, @max(@as(f64, @floatFromInt(samples.len)) - 1, 0)).intersectPositive(options.x_range) }; @@ -246,10 +377,10 @@ pub fn drawCached(cache: *Cache, render_size: Vec2, options: ViewOptions, sample .width = render_size.x, .height = render_size.y }; - drawSamples(draw_rect, options, samples); + drawSamples(draw_rect, options, samples, cache.min_max_cache); } -pub fn draw(cache: ?*Cache, draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f64) void { +pub fn draw(cache: ?*RenderCache, draw_rect: rl.Rectangle, options: ViewOptions, samples: []const f64) void { if (draw_rect.width < 0 or draw_rect.height < 0) { return; } @@ -259,6 +390,6 @@ pub fn draw(cache: ?*Cache, draw_rect: rl.Rectangle, options: ViewOptions, sampl drawCached(c, .{ .x = draw_rect.width, .y = draw_rect.height }, options, samples); c.draw(draw_rect); } else { - drawSamples(draw_rect, options, samples); + drawSamples(draw_rect, options, samples, null); } } \ No newline at end of file diff --git a/src/main.zig b/src/main.zig index 961307f..1f5392a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -102,6 +102,10 @@ pub fn main() !void { defer app.deinit(); if (builtin.mode == .Debug) { + _ = try app.addView(.{ + .channel = try app.addChannel("Dev1/ai0") + }); + _ = try app.addView(.{ .file = try app.addFile("./samples-5k.bin") }); diff --git a/src/screens/main_screen.zig b/src/screens/main_screen.zig index 8fe92a6..60333c5 100644 --- a/src/screens/main_screen.zig +++ b/src/screens/main_screen.zig @@ -27,7 +27,7 @@ view_controls: ViewControlsSystem, frequency_input: UI.TextInputStorage, amplitude_input: UI.TextInputStorage, protocol_error_message: ?[]const u8 = null, -protocol_graph_cache: Graph.Cache = .{}, +protocol_graph_cache: Graph.RenderCache = .{}, preview_samples: std.ArrayListUnmanaged(f64) = .{}, preview_samples_y_range: RangeF64 = RangeF64.init(0, 0),