lib/gui/src/paint/cpu/render.zig

daab053ee43316e1809a84551d573ddd1e5bf3d2

  1 const std = @import("std");
  2 
  3 const gui = @import("../../root.zig");
  4 const paint_root = @import("../root.zig");
  5 
  6 pub const geometry = @import("geometry.zig");
  7 pub const pixel = @import("pixel.zig");
  8 const span = @import("span.zig");
  9 
 10 const Color = gui.model.UiColor;
 11 const Command = paint_root.command.Command;
 12 const ImageSet = paint_root.command.ImageSet;
 13 
 14 pub const Region = geometry.Region;
 15 pub const Bounds = geometry.Bounds;
 16 pub const clippedBounds = geometry.clippedBounds;
 17 pub const packRgba = pixel.packRgba;
 18 
 19 pub const Target = struct {
 20     width: u32,
 21     height: u32,
 22     rgba8: []u8,
 23 
 24     pub fn byteCount(self: Target) usize {
 25         return @as(usize, self.width) * @as(usize, self.height) * 4;
 26     }
 27 
 28     pub fn validate(self: Target) !void {
 29         if (self.rgba8.len < self.byteCount()) return error.BufferTooSmall;
 30     }
 31 };
 32 
 33 pub const PackedTarget = struct {
 34     width: u32,
 35     height: u32,
 36     pixels: []u32,
 37 
 38     pub fn pixelCount(self: PackedTarget) usize {
 39         return @as(usize, self.width) * @as(usize, self.height);
 40     }
 41 
 42     pub fn validate(self: PackedTarget) !void {
 43         if (self.pixels.len < self.pixelCount()) return error.BufferTooSmall;
 44     }
 45 };
 46 
 47 pub fn renderCommands(commands: []const Command, target: Target, clear: Color) !void {
 48     try renderCommandsWithImages(commands, target, clear, .{});
 49 }
 50 
 51 pub fn renderCommandsWithImages(commands: []const Command, target: Target, clear: Color, images: ImageSet) !void {
 52     try renderCommandsRegionWithImages(commands, target, clear, images, Region.full(target.width, target.height));
 53 }
 54 
 55 pub fn renderCommandsRegionWithImages(commands: []const Command, target: Target, clear: Color, images: ImageSet, region: Region) !void {
 56     try target.validate();
 57     const active_region = region.clamped(target.width, target.height);
 58     if (active_region.pixelCount() == 0) return;
 59     span.renderRegion(pixel.ByteStore, .{ .bytes = target.rgba8 }, target.width, target.height, commands, clear, images, active_region);
 60 }
 61 
 62 pub fn renderCommandsPacked(commands: []const Command, target: PackedTarget, clear: Color) !void {
 63     try renderCommandsPackedWithImages(commands, target, clear, .{});
 64 }
 65 
 66 pub fn renderCommandsPackedWithImages(commands: []const Command, target: PackedTarget, clear: Color, images: ImageSet) !void {
 67     try renderCommandsPackedRegionWithImages(commands, target, clear, images, Region.full(target.width, target.height));
 68 }
 69 
 70 pub fn renderCommandsPackedRegionWithImages(commands: []const Command, target: PackedTarget, clear: Color, images: ImageSet, region: Region) !void {
 71     try target.validate();
 72     const active_region = region.clamped(target.width, target.height);
 73     if (active_region.pixelCount() == 0) return;
 74     span.renderRegion(pixel.WordStore, .{ .words = target.pixels }, target.width, target.height, commands, clear, images, active_region);
 75 }
 76 
 77 pub fn rgba8FromPacked(dst: []u8, pixels: []const u32) !void {
 78     if (dst.len < pixels.len * 4) return error.BufferTooSmall;
 79     for (pixels, 0..) |value, index| pixel.writePacked(dst, index, value);
 80 }
 81 
 82 test "renderCommands paints clipped alpha blended rgba8" {
 83     var pixels = @as([(8 * 4 * 4)]u8, @splat(0));
 84     const commands = [_]Command{
 85         .{
 86             .kind = .fill,
 87             .rect = .{ .x = 1, .y = 1, .width = 4, .height = 2 },
 88             .clip = .{ .x = 0, .y = 0, .width = 8, .height = 4 },
 89             .color = .{ .r = 200, .g = 20, .b = 10, .a = 255 },
 90         },
 91         .{
 92             .kind = .fill,
 93             .rect = .{ .x = 3, .y = 1, .width = 4, .height = 2 },
 94             .clip = .{ .x = 0, .y = 0, .width = 5, .height = 4 },
 95             .color = .{ .r = 0, .g = 100, .b = 200, .a = 128 },
 96         },
 97     };
 98 
 99     try renderCommands(commands[0..], .{ .width = 8, .height = 4, .rgba8 = pixels[0..] }, .{ .a = 0 });
100 
101     try std.testing.expectEqual(packRgba(.{ .r = 200, .g = 20, .b = 10, .a = 255 }), pixel.readPacked(pixels[0..], 1 + 1 * 8));
102     try std.testing.expectEqual(packRgba(.{ .r = 100, .g = 60, .b = 105, .a = 255 }), pixel.readPacked(pixels[0..], 3 + 1 * 8));
103     try std.testing.expectEqual(@as(u32, 0), pixel.readPacked(pixels[0..], 5 + 1 * 8));
104 }
105 
106 test "renderCommandsRegionWithImages preserves rgba8 pixels outside region" {
107     var pixels = @as([(5 * 4 * 4)]u8, @splat(0xaa));
108     const commands = [_]Command{.{
109         .kind = .fill,
110         .rect = .{ .x = 0, .y = 0, .width = 5, .height = 4 },
111         .clip = .{ .x = 0, .y = 0, .width = 5, .height = 4 },
112         .color = .{ .r = 200, .g = 0, .b = 0, .a = 255 },
113     }};
114 
115     try renderCommandsRegionWithImages(commands[0..], .{ .width = 5, .height = 4, .rgba8 = pixels[0..] }, .{ .a = 0 }, .{}, .{ .x = 1, .y = 1, .width = 2, .height = 2 });
116 
117     try std.testing.expectEqual(@as(u32, 0xaaaa_aaaa), pixel.readPacked(pixels[0..], 0));
118     try std.testing.expectEqual(packRgba(.{ .r = 200, .g = 0, .b = 0, .a = 255 }), pixel.readPacked(pixels[0..], 1 + 1 * 5));
119     try std.testing.expectEqual(@as(u32, 0xaaaa_aaaa), pixel.readPacked(pixels[0..], 4 + 3 * 5));
120 }
121 
122 test "rounded stroke covers border pixels and leaves center" {
123     var pixels = @as([(9 * 9)]u32, @splat(0));
124     const commands = [_]Command{.{
125         .kind = .stroke,
126         .rect = .{ .x = 1, .y = 1, .width = 7, .height = 7 },
127         .clip = .{ .x = 0, .y = 0, .width = 9, .height = 9 },
128         .color = .{ .r = 10, .g = 20, .b = 30, .a = 255 },
129         .radius = 3,
130         .width = 2,
131     }};
132 
133     try renderCommandsPacked(commands[0..], .{ .width = 9, .height = 9, .pixels = pixels[0..] }, .{ .a = 0 });
134 
135     try std.testing.expectEqual(packRgba(.{ .r = 10, .g = 20, .b = 30, .a = 255 }), pixels[4 + 1 * 9]);
136     try std.testing.expectEqual(@as(u32, 0), pixels[4 + 4 * 9]);
137 }
138 
139 test "renderCommandsPackedRegionWithImages preserves packed pixels outside region" {
140     var pixels = @as([(5 * 4)]u32, @splat(0x1122_3344));
141     const commands = [_]Command{.{
142         .kind = .fill,
143         .rect = .{ .x = 0, .y = 0, .width = 5, .height = 4 },
144         .clip = .{ .x = 0, .y = 0, .width = 5, .height = 4 },
145         .color = .{ .r = 10, .g = 20, .b = 30, .a = 255 },
146     }};
147 
148     try renderCommandsPackedRegionWithImages(commands[0..], .{ .width = 5, .height = 4, .pixels = pixels[0..] }, .{ .a = 0 }, .{}, .{ .x = 2, .y = 1, .width = 2, .height = 2 });
149 
150     try std.testing.expectEqual(@as(u32, 0x1122_3344), pixels[0]);
151     try std.testing.expectEqual(packRgba(.{ .r = 10, .g = 20, .b = 30, .a = 255 }), pixels[2 + 1 * 5]);
152     try std.testing.expectEqual(@as(u32, 0x1122_3344), pixels[4 + 3 * 5]);
153 }
154 
155 test "fractional fill edges scale alpha coverage" {
156     var pixels = @as([(4 * 4)]u32, @splat(0));
157     const commands = [_]Command{.{
158         .kind = .fill,
159         .rect = .{ .x = 1.5, .y = 1.5, .width = 2, .height = 2 },
160         .clip = .{ .x = 0, .y = 0, .width = 4, .height = 4 },
161         .color = .{ .r = 200, .g = 0, .b = 0, .a = 255 },
162     }};
163 
164     try renderCommandsPacked(commands[0..], .{ .width = 4, .height = 4, .pixels = pixels[0..] }, .{ .a = 0 });
165 
166     try std.testing.expectEqual(packRgba(.{ .r = 50, .g = 0, .b = 0, .a = 64 }), pixels[1 + 1 * 4]);
167     try std.testing.expectEqual(packRgba(.{ .r = 200, .g = 0, .b = 0, .a = 255 }), pixels[2 + 2 * 4]);
168     try std.testing.expectEqual(@as(u32, 0), pixels[0]);
169 }
170 
171 test "linear gradient raster fixes endpoint clamp and rounded clipping pixels" {
172     var pixels = @as([(9 * 9)]u32, @splat(0));
173     const start = Color{ .r = 10, .g = 20, .b = 30, .a = 255 };
174     const end = Color{ .r = 210, .g = 120, .b = 80, .a = 255 };
175     const commands = [_]Command{.{
176         .kind = .linear_gradient,
177         .rect = .{ .x = 1, .y = 1, .width = 7, .height = 7 },
178         .clip = .{ .width = 9, .height = 9 },
179         .color = start,
180         .color_end = end,
181         .gradient_start = .{ .x = 2.5, .y = 4.5 },
182         .gradient_end = .{ .x = 6.5, .y = 4.5 },
183         .radius = 3,
184     }};
185 
186     try renderCommandsPacked(commands[0..], .{ .width = 9, .height = 9, .pixels = pixels[0..] }, .{ .a = 0 });
187 
188     try std.testing.expectEqual(@as(u32, 0), pixels[1 + 1 * 9]);
189     try std.testing.expectEqual(packRgba(start), pixels[1 + 4 * 9]);
190     try std.testing.expectEqual(packRgba(start), pixels[2 + 4 * 9]);
191     try std.testing.expectEqual(packRgba(.{ .r = 110, .g = 70, .b = 55, .a = 255 }), pixels[4 + 4 * 9]);
192     try std.testing.expectEqual(packRgba(end), pixels[6 + 4 * 9]);
193     try std.testing.expectEqual(packRgba(end), pixels[7 + 4 * 9]);
194 }
195 
196 test "linear gradient translucent stop preserves hue over an opaque surface" {
197     var pixels = @as([5]u32, @splat(0));
198     const commands = [_]Command{
199         .{
200             .kind = .fill,
201             .rect = .{ .width = 5, .height = 1 },
202             .clip = .{ .width = 5, .height = 1 },
203             .color = .{ .r = 10, .g = 20, .b = 30, .a = 255 },
204         },
205         .{
206             .kind = .linear_gradient,
207             .rect = .{ .width = 5, .height = 1 },
208             .clip = .{ .width = 5, .height = 1 },
209             .color = .{ .r = 255, .g = 40, .b = 20, .a = 255 },
210             .color_end = .{ .a = 0 },
211             .gradient_start = .{ .x = 0.5, .y = 0.5 },
212             .gradient_end = .{ .x = 4.5, .y = 0.5 },
213         },
214     };
215 
216     try renderCommandsPacked(commands[0..], .{ .width = 5, .height = 1, .pixels = pixels[0..] }, .{ .a = 0 });
217 
218     try std.testing.expectEqual(packRgba(.{ .r = 133, .g = 30, .b = 25, .a = 255 }), pixels[2]);
219     try std.testing.expectEqual(packRgba(.{ .r = 10, .g = 20, .b = 30, .a = 255 }), pixels[4]);
220 }
221 
222 fn gradientStateWidget(
223     widget_id: u64,
224     rect: gui.layout.Rect,
225     gradient: gui.model.UiLinearGradient,
226     state_flags: u64,
227     hovered: bool,
228     focused: bool,
229 ) gui.model.WidgetFrame {
230     return .{
231         .root_id = 1,
232         .widget_id = widget_id,
233         .kind = .button,
234         .rect = rect,
235         .visible_rect = rect,
236         .paint = .{
237             .linear_gradient = gradient,
238             .border = .{ .r = 132, .g = 148, .b = 196, .a = 112 },
239             .border_width = 1,
240             .corner_roundness = 18,
241             .shadow = .{
242                 .color = .{ .a = 112 },
243                 .offset_y = 4,
244                 .blur_radius = 8,
245                 .spread = 1,
246             },
247         },
248         .scroll = .{},
249         .constraints = .{},
250         .content_size = .{ .width = rect.width, .height = rect.height },
251         .focusable = true,
252         .hovered = hovered,
253         .focused = focused,
254         .state_flags = state_flags,
255     };
256 }
257 
258 test "linear gradient widget states compose into the CPU raster" {
259     const target_width: u32 = 960;
260     const target_height: u32 = 300;
261     const background = Color{ .r = 13, .g = 17, .b = 29, .a = 255 };
262     const gradient = gui.model.UiLinearGradient{
263         .start = .{ .y = 0.08 },
264         .end = .{ .x = 1, .y = 0.92 },
265         .start_color = .{ .r = 34, .g = 43, .b = 70, .a = 255 },
266         .end_color = .{ .r = 91, .g = 72, .b = 137, .a = 255 },
267     };
268     const root_rect = gui.layout.Rect{
269         .width = @floatFromInt(target_width),
270         .height = @floatFromInt(target_height),
271     };
272     const widgets = [_]gui.model.WidgetFrame{
273         .{
274             .root_id = 1,
275             .widget_id = 1,
276             .kind = .container,
277             .rect = root_rect,
278             .visible_rect = root_rect,
279             .paint = .{ .background = background },
280             .scroll = .{},
281             .constraints = .{},
282             .content_size = .{ .width = root_rect.width, .height = root_rect.height },
283             .focusable = false,
284         },
285         gradientStateWidget(2, .{ .x = 42, .y = 64, .width = 192, .height = 112 }, gradient, gui.model.ui_state_selected, false, false),
286         gradientStateWidget(3, .{ .x = 270, .y = 64, .width = 192, .height = 112 }, gradient, gui.model.ui_state_disabled, false, false),
287         gradientStateWidget(4, .{ .x = 498, .y = 64, .width = 192, .height = 112 }, gradient, 0, true, false),
288         gradientStateWidget(5, .{ .x = 726, .y = 64, .width = 192, .height = 112 }, gradient, 0, false, true),
289         .{
290             .root_id = 1,
291             .widget_id = 6,
292             .kind = .container,
293             .rect = .{ .x = 330, .y = 218, .width = 300, .height = 44 },
294             .visible_rect = .{ .x = 330, .y = 218, .width = 300, .height = 44 },
295             .paint = .{
296                 .linear_gradient = .{
297                     .start_color = .{ .r = 255, .g = 105, .b = 86, .a = 230 },
298                     .end_color = .{ .a = 0 },
299                 },
300                 .corner_roundness = 22,
301             },
302             .scroll = .{},
303             .constraints = .{},
304             .content_size = .{ .width = 300, .height = 44 },
305             .focusable = false,
306         },
307     };
308     const frame = gui.model.UiFrame{
309         .root_count = 1,
310         .widget_count = widgets.len,
311         .widgets = widgets[0..],
312     };
313     var commands = paint_root.command.CommandBuffer.init(std.testing.allocator);
314     defer commands.deinit();
315     try commands.appendFrame(frame, 1, target_width, target_height, .{
316         .visual = .{ .palette = .{
317             .panel = .{ .r = 184, .g = 194, .b = 222, .a = 255 },
318             .panel_alt = .{ .r = 43, .g = 50, .b = 76, .a = 255 },
319             .text = .{ .r = 238, .g = 242, .b = 255, .a = 255 },
320             .muted = .{ .r = 156, .g = 165, .b = 194, .a = 255 },
321             .border = .{ .r = 132, .g = 148, .b = 196, .a = 255 },
322             .accent = .{ .r = 109, .g = 140, .b = 255, .a = 255 },
323             .danger = .{ .r = 255, .g = 92, .b = 111, .a = 255 },
324             .focus_fill = .{ .r = 109, .g = 140, .b = 255, .a = 38 },
325             .hover_fill = .{ .r = 109, .g = 140, .b = 255, .a = 26 },
326             .selected_fill = .{ .r = 109, .g = 140, .b = 255, .a = 48 },
327         } },
328     });
329     var gradient_count: usize = 0;
330     for (commands.items()) |paint| {
331         if (paint.kind == .linear_gradient) gradient_count += 1;
332     }
333     try std.testing.expectEqual(@as(usize, 5), gradient_count);
334 
335     const pixels = try std.testing.allocator.alloc(u32, @as(usize, target_width) * target_height);
336     defer std.testing.allocator.free(pixels);
337     try renderCommandsPacked(commands.items(), .{
338         .width = target_width,
339         .height = target_height,
340         .pixels = pixels,
341     }, .{ .a = 0 });
342 
343     const background_pixel = packRgba(background);
344     try std.testing.expectEqual(background_pixel, pixels[0]);
345     const selected = pixels[138 + 120 * target_width];
346     const disabled = pixels[366 + 120 * target_width];
347     const hovered = pixels[594 + 120 * target_width];
348     const focused = pixels[822 + 120 * target_width];
349     try std.testing.expect(selected != background_pixel);
350     try std.testing.expect(disabled != background_pixel);
351     try std.testing.expect(hovered != background_pixel);
352     try std.testing.expect(focused != background_pixel);
353     try std.testing.expect(selected != disabled);
354     try std.testing.expect(hovered != focused);
355     try std.testing.expect(pixels[724 + 120 * target_width] != background_pixel);
356     try std.testing.expectEqual(background_pixel, pixels[629 + 240 * target_width]);
357     try std.testing.expect(@as(u8, @truncate(pixels[480 + 240 * target_width])) > 80);
358 }
359 
360 test "image command samples packed image pixels" {
361     const image_pixels = [_]u32{
362         packRgba(.{ .r = 255, .g = 0, .b = 0, .a = 255 }),
363         packRgba(.{ .r = 0, .g = 255, .b = 0, .a = 255 }),
364         packRgba(.{ .r = 0, .g = 0, .b = 255, .a = 128 }),
365         packRgba(.{ .r = 255, .g = 255, .b = 255, .a = 255 }),
366     };
367     const images = ImageSet{ .images = &.{.{ .width = 2, .height = 2, .pixels = image_pixels[0..] }} };
368     var pixels = @as([(4 * 4)]u32, @splat(0));
369     const commands = [_]Command{.{
370         .kind = .image,
371         .rect = .{ .x = 1, .y = 1, .width = 2, .height = 2 },
372         .clip = .{ .x = 0, .y = 0, .width = 4, .height = 4 },
373         .source = .{ .x = 0, .y = 0, .width = 2, .height = 2 },
374         .color = .{ .r = 255, .g = 255, .b = 255, .a = 255 },
375         .image_index = 0,
376     }};
377 
378     try renderCommandsPackedWithImages(commands[0..], .{ .width = 4, .height = 4, .pixels = pixels[0..] }, .{ .a = 0 }, images);
379 
380     try std.testing.expectEqual(image_pixels[0], pixels[1 + 1 * 4]);
381     try std.testing.expectEqual(packRgba(.{ .r = 0, .g = 0, .b = 128, .a = 128 }), pixels[1 + 2 * 4]);
382     try std.testing.expectEqual(image_pixels[3], pixels[2 + 2 * 4]);
383 }
384 
385 test "glyph command tints atlas alpha" {
386     const atlas_pixels = [_]u32{
387         packRgba(.{ .r = 255, .g = 255, .b = 255, .a = 128 }),
388     };
389     const images = ImageSet{ .images = &.{.{ .width = 1, .height = 1, .pixels = atlas_pixels[0..] }} };
390     var pixels = @as([1]u32, @splat(0));
391     const commands = [_]Command{.{
392         .kind = .glyph,
393         .rect = .{ .x = 0, .y = 0, .width = 1, .height = 1 },
394         .clip = .{ .x = 0, .y = 0, .width = 1, .height = 1 },
395         .source = .{ .x = 0, .y = 0, .width = 1, .height = 1 },
396         .color = .{ .r = 255, .g = 0, .b = 0, .a = 200 },
397         .image_index = 0,
398     }};
399 
400     try renderCommandsPackedWithImages(commands[0..], .{ .width = 1, .height = 1, .pixels = pixels[0..] }, .{ .a = 0 }, images);
401 
402     try std.testing.expectEqual(packRgba(.{ .r = 100, .g = 0, .b = 0, .a = 100 }), pixels[0]);
403 }
404 
405 test "shadow command paints outside source rect with falloff" {
406     var pixels = @as([(7 * 5)]u32, @splat(0));
407     const commands = [_]Command{.{
408         .kind = .shadow,
409         .rect = .{ .x = 3, .y = 2, .width = 2, .height = 1 },
410         .clip = .{ .x = 0, .y = 0, .width = 7, .height = 5 },
411         .color = .{ .r = 0, .g = 0, .b = 0, .a = 200 },
412         .width = 2,
413     }};
414 
415     try renderCommandsPacked(commands[0..], .{ .width = 7, .height = 5, .pixels = pixels[0..] }, .{ .a = 0 });
416 
417     const core_alpha = pixels[3 + 2 * 7] >> 24;
418     const falloff_alpha = pixels[2 + 2 * 7] >> 24;
419     try std.testing.expectEqual(@as(u32, 200), core_alpha);
420     try std.testing.expect(falloff_alpha > 0);
421     try std.testing.expect(falloff_alpha < core_alpha);
422     try std.testing.expectEqual(@as(u32, 0), pixels[0]);
423 }