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 }