lib/gui/src/paint/image.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const gpu = @import("gpu");
3 const choir_abi = @import("choir_abi");
4 const accy = @import("accy");
5 const alloc_phase = @import("alloc_phase");
6
7 const command = @import("command.zig");
8 const cpu = @import("cpu/root.zig");
9
10 const Allocator = std.mem.Allocator;
11 const library = accy.kernel.library;
12 const image_library = library.image;
13 const host_loop_launch_shape_arg_count: usize = choir_abi.launch_shape_argument_count;
14 const runtime_argument_count_max: usize = 7;
15 const scalar_argument_count_max: usize = runtime_argument_count_max + host_loop_launch_shape_arg_count;
16 const Color = @import("../root.zig").model.UiColor;
17 const Rect = @import("../root.zig").layout.Rect;
18 const ImageThreads = @TypeOf(image_library.imageThreadsForExtents(1, 1));
19
20 pub const Axis = image_library.Axis;
21
22 const ImageLimits = struct {
23 dst_pixels: usize = 0,
24 src_pixels: usize = 0,
25 scratch_pixels: usize = 0,
26 weight_taps: usize = 0,
27
28 fn merged(self: Limits, demand: Limits) Limits {
29 return .{
30 .dst_pixels = @max(self.dst_pixels, demand.dst_pixels),
31 .src_pixels = @max(self.src_pixels, demand.src_pixels),
32 .scratch_pixels = @max(self.scratch_pixels, demand.scratch_pixels),
33 .weight_taps = @max(self.weight_taps, demand.weight_taps),
34 };
35 }
36 };
37 pub const Limits = ImageLimits;
38
39 const ImageCapacity = struct {
40 limits: Limits,
41 host_storage_bytes: usize,
42 device_storage_bytes: usize,
43 device_buffer_count: usize,
44 total_storage_bytes: usize,
45
46 pub fn derive(limits: Limits) error{CapacityOverflow}!Capacity {
47 const host_elements = std.math.add(usize, limits.dst_pixels, limits.weight_taps) catch return error.CapacityOverflow;
48 const host_storage_bytes = std.math.mul(usize, host_elements, @sizeOf(u32)) catch return error.CapacityOverflow;
49 var device_elements: usize = 0;
50 var device_buffer_count: usize = 0;
51 const counts = [_]usize{ limits.dst_pixels, limits.src_pixels, limits.scratch_pixels, limits.weight_taps };
52 for (counts) |count| {
53 device_elements = std.math.add(usize, device_elements, count) catch return error.CapacityOverflow;
54 device_buffer_count += @intFromBool(count != 0);
55 }
56 const device_storage_bytes = std.math.mul(usize, device_elements, @sizeOf(u32)) catch return error.CapacityOverflow;
57 return .{
58 .limits = limits,
59 .host_storage_bytes = host_storage_bytes,
60 .device_storage_bytes = device_storage_bytes,
61 .device_buffer_count = device_buffer_count,
62 .total_storage_bytes = std.math.add(usize, host_storage_bytes, device_storage_bytes) catch return error.CapacityOverflow,
63 };
64 }
65
66 pub fn admits(self: Capacity, demand: Limits) bool {
67 return self.limits.dst_pixels >= demand.dst_pixels and
68 self.limits.src_pixels >= demand.src_pixels and
69 self.limits.scratch_pixels >= demand.scratch_pixels and
70 self.limits.weight_taps >= demand.weight_taps;
71 }
72 };
73 pub const Capacity = ImageCapacity;
74
75 pub const StorageStatus = struct {
76 limits: ?Limits,
77 capacity: ?Capacity,
78 replacements: usize,
79 };
80
81 pub const Options = struct {
82 artifact_format: ?gpu.ArtifactFormat = null,
83 initial_storage: ?Limits = null,
84 };
85
86 const ImageLaunchScalars = struct {
87 storage: [scalar_argument_count_max]choir_abi.ScalarArgument,
88 count: usize,
89
90 fn slice(self: *const ImageLaunchScalars) []const choir_abi.ScalarArgument {
91 return self.storage[0..self.count];
92 }
93 };
94
95 fn imageLaunchScalars(
96 entry: anytype,
97 format: gpu.ArtifactFormat,
98 geometry: choir_abi.LaunchGeometry,
99 runtime_arguments: []const choir_abi.ScalarArgument,
100 ) !ImageLaunchScalars {
101 if (runtime_arguments.len > runtime_argument_count_max) return error.LaunchArgumentMismatch;
102 var result = ImageLaunchScalars{ .storage = undefined, .count = runtime_arguments.len };
103 @memcpy(result.storage[0..runtime_arguments.len], runtime_arguments);
104 if (gpu.artifactFormatUsesHostLoopLaunch(format)) {
105 if (entry.static_arguments.len != host_loop_launch_shape_arg_count) return error.InvalidArtifact;
106 const shape = try choir_abi.launchShape(try geometry.threadCount(), geometry);
107 try shape.scalarArguments(result.storage[result.count..]);
108 result.count += host_loop_launch_shape_arg_count;
109 } else {
110 if (entry.static_arguments.len > host_loop_launch_shape_arg_count) return error.InvalidArtifact;
111 @memcpy(result.storage[result.count..][0..entry.static_arguments.len], entry.static_arguments);
112 result.count += entry.static_arguments.len;
113 }
114 return result;
115 }
116
117 pub const ShadowOptions = struct {
118 image_index: u32 = 0,
119 sigma: ?f32 = null,
120 };
121
122 pub const ShadowExpansionOptions = struct {
123 sigma: ?f32 = null,
124 };
125
126 pub const ThumbnailOptions = struct {
127 max_width: u32,
128 max_height: u32,
129 };
130
131 pub const ResizedImage = struct {
132 image: command.Image,
133 pixels: []u32,
134
135 pub fn deinit(self: *ResizedImage, allocator: Allocator) void {
136 allocator.free(self.pixels);
137 self.* = undefined;
138 }
139 };
140
141 pub const Thumbnail = ResizedImage;
142
143 pub const Shadow = struct {
144 command: command.Command,
145 image: command.Image,
146 pixels: []u32,
147
148 pub fn deinit(self: *Shadow, allocator: Allocator) void {
149 allocator.free(self.pixels);
150 self.* = undefined;
151 }
152 };
153
154 pub const ShadowExpansion = struct {
155 allocator: Allocator,
156 commands: []const command.Command,
157 image_entries: []const command.Image,
158 shadows: []Shadow,
159
160 pub fn imageSet(self: *const ShadowExpansion) command.ImageSet {
161 return .{ .images = self.image_entries };
162 }
163
164 pub fn refreshReusableCommands(self: *ShadowExpansion, commands: []const command.Command) !void {
165 if (commands.len != self.commands.len) return error.InvalidShadowCommand;
166 const rewritten = @constCast(self.commands);
167 var shadow_index: usize = 0;
168 for (commands, 0..) |paint, index| {
169 if (paint.kind == .shadow) {
170 if (shadow_index >= self.shadows.len) return error.InvalidShadowCommand;
171 tintShadowPixels(self.shadows[shadow_index].pixels, paint.color);
172 rewritten[index] = self.shadows[shadow_index].command;
173 shadow_index += 1;
174 } else {
175 rewritten[index] = paint;
176 }
177 }
178 if (shadow_index != self.shadows.len) return error.InvalidShadowCommand;
179 }
180
181 pub fn deinit(self: *ShadowExpansion) void {
182 for (self.shadows) |*shadow| shadow.deinit(self.allocator);
183 self.allocator.free(@constCast(self.commands));
184 self.allocator.free(@constCast(self.image_entries));
185 self.allocator.free(self.shadows);
186 self.* = undefined;
187 }
188 };
189
190 pub const Processor = struct {
191 allocator: Allocator,
192 handle: gpu.BackendHandle,
193 format: gpu.ArtifactFormat,
194 kernels: std.ArrayListUnmanaged(CachedImageKernel) = .empty,
195 buffers: ImageBuffers = .{},
196 shadow_alpha: ?CachedShadowAlpha = null,
197 storage_replacements: usize = 0,
198
199 pub fn init(allocator: Allocator, handle: gpu.BackendHandle, options: Options) !Processor {
200 var result = Processor{
201 .allocator = allocator,
202 .handle = handle,
203 .format = options.artifact_format orelse try defaultFormat(handle),
204 };
205 if (options.initial_storage) |limits| {
206 result.buffers = try ImageBuffers.init(allocator, handle, limits);
207 }
208 return result;
209 }
210
211 pub fn deinit(self: *Processor) void {
212 if (self.shadow_alpha) |*cached| cached.deinit(self.allocator);
213 self.buffers.deinit(self.allocator, self.handle);
214 for (self.kernels.items) |*cached| cached.deinit(self.handle);
215 self.kernels.deinit(self.allocator);
216 self.* = undefined;
217 }
218
219 pub fn storageStatus(self: *const Processor) StorageStatus {
220 return .{
221 .limits = if (self.buffers.capacity) |capacity| capacity.limits else null,
222 .capacity = self.buffers.capacity,
223 .replacements = self.storage_replacements,
224 };
225 }
226
227 pub fn blurPass(
228 self: *Processor,
229 dst: []u32,
230 src: []const u32,
231 width: u32,
232 height: u32,
233 radius: u32,
234 sigma: f32,
235 axis: Axis,
236 ) !void {
237 const pixels = try validateImageSlices(dst, src, width, height);
238 const taps = try blurTapCount(radius);
239 try self.ensureStorage(.{
240 .dst_pixels = pixels,
241 .src_pixels = pixels,
242 .weight_taps = taps,
243 });
244
245 const dst_buffer = self.buffers.dst.?.handle;
246 const src_buffer = self.buffers.src.?.handle;
247 const weights_buffer = try self.buffers.ensureWeightsData(self.handle, radius, sigma);
248
249 try self.handle.writeBuffer(.{
250 .handle = src_buffer,
251 .bytes = std.mem.sliceAsBytes(src[0..pixels]),
252 });
253 try self.launchBlurPass(dst_buffer, src_buffer, weights_buffer, width, height, radius, axis);
254 try self.buffers.readPixels(self.handle, dst_buffer, dst, pixels);
255 }
256
257 pub fn resizeBilinear(
258 self: *Processor,
259 dst: []u32,
260 src: []const u32,
261 dst_width: u32,
262 dst_height: u32,
263 src_width: u32,
264 src_height: u32,
265 ) !void {
266 const dst_pixels = try pixelCount(dst_width, dst_height);
267 const src_pixels = try pixelCount(src_width, src_height);
268 if (dst.len < dst_pixels or src.len < src_pixels) return error.BufferTooSmall;
269 try self.ensureStorage(.{
270 .dst_pixels = dst_pixels,
271 .src_pixels = src_pixels,
272 });
273
274 const dst_buffer = self.buffers.dst.?.handle;
275 const src_buffer = self.buffers.src.?.handle;
276
277 try self.handle.writeBuffer(.{
278 .handle = src_buffer,
279 .bytes = std.mem.sliceAsBytes(src[0..src_pixels]),
280 });
281 try self.launchResizeBilinear(dst_buffer, src_buffer, dst_width, dst_height, src_width, src_height);
282 try self.buffers.readPixels(self.handle, dst_buffer, dst, dst_pixels);
283 }
284
285 pub fn resizeAlloc(
286 self: *Processor,
287 source: command.Image,
288 width: u32,
289 height: u32,
290 ) !ResizedImage {
291 try source.validate();
292 const pixels = try pixelCount(width, height);
293 const dst = try self.allocator.alloc(u32, pixels);
294 errdefer self.allocator.free(dst);
295 try self.resizeBilinear(dst, source.pixels, width, height, source.width, source.height);
296 return .{
297 .image = .{
298 .width = width,
299 .height = height,
300 .pixels = dst,
301 },
302 .pixels = dst,
303 };
304 }
305
306 pub fn thumbnailAlloc(
307 self: *Processor,
308 source: command.Image,
309 options: ThumbnailOptions,
310 ) !Thumbnail {
311 try source.validate();
312 const extent = try thumbnailExtent(source.width, source.height, options.max_width, options.max_height);
313 if (extent.width == source.width and extent.height == source.height) {
314 const pixels = try pixelCount(source.width, source.height);
315 const dst = try self.allocator.dupe(u32, source.pixels[0..pixels]);
316 errdefer self.allocator.free(dst);
317 return .{
318 .image = .{
319 .width = source.width,
320 .height = source.height,
321 .pixels = dst,
322 },
323 .pixels = dst,
324 };
325 }
326 return self.resizeAlloc(source, extent.width, extent.height);
327 }
328
329 pub fn shadowAlloc(self: *Processor, paint: command.Command, options: ShadowOptions) !Shadow {
330 const plan = try planShadow(paint, options);
331
332 const pixels = try self.allocator.alloc(u32, plan.pixels);
333 errdefer self.allocator.free(pixels);
334 if (self.useShadowAlphaCache(plan.key, pixels)) {
335 tintShadowPixels(pixels, paint.color);
336 return plan.shadow(pixels);
337 }
338
339 const mask = try self.allocator.alloc(u32, plan.pixels);
340 defer self.allocator.free(mask);
341 rasterShadowMask(mask, plan.frame, paint);
342
343 if (plan.blur_radius > 0) {
344 try self.blurTwoPass(
345 pixels,
346 mask,
347 plan.width,
348 plan.height,
349 plan.blur_radius,
350 plan.sigma,
351 );
352 } else {
353 @memcpy(pixels, mask);
354 }
355 try self.storeShadowAlpha(plan.key, pixels);
356 tintShadowPixels(pixels, paint.color);
357 return plan.shadow(pixels);
358 }
359
360 pub fn expandShadowsAlloc(
361 self: *Processor,
362 commands: []const command.Command,
363 images: command.ImageSet,
364 options: ShadowExpansionOptions,
365 ) !ShadowExpansion {
366 const shadow_count = countShadows(commands);
367 const image_count = std.math.add(usize, images.images.len, shadow_count) catch return error.ImageCountTooLarge;
368 if (image_count > std.math.maxInt(u32)) return error.ImageCountTooLarge;
369
370 const rewritten = try self.allocator.alloc(command.Command, commands.len);
371 errdefer self.allocator.free(rewritten);
372 const image_entries = try self.allocator.alloc(command.Image, image_count);
373 errdefer self.allocator.free(image_entries);
374 if (images.images.len != 0) @memcpy(image_entries[0..images.images.len], images.images);
375
376 const shadows = try self.allocator.alloc(Shadow, shadow_count);
377 var shadow_init: usize = 0;
378 errdefer {
379 for (shadows[0..shadow_init]) |*shadow| shadow.deinit(self.allocator);
380 self.allocator.free(shadows);
381 }
382
383 var generated_index: usize = 0;
384 for (commands, 0..) |paint, index| {
385 if (paint.kind == .shadow) {
386 const image_index_usize = images.images.len + generated_index;
387 const image_index: u32 = @intCast(image_index_usize);
388 const generated = try self.shadowAlloc(paint, .{
389 .image_index = image_index,
390 .sigma = options.sigma,
391 });
392 shadows[generated_index] = generated;
393 shadow_init += 1;
394 image_entries[image_index_usize] = generated.image;
395 rewritten[index] = generated.command;
396 generated_index += 1;
397 } else {
398 rewritten[index] = paint;
399 }
400 }
401
402 return .{
403 .allocator = self.allocator,
404 .commands = rewritten,
405 .image_entries = image_entries,
406 .shadows = shadows,
407 };
408 }
409
410 fn blurTwoPass(
411 self: *Processor,
412 dst: []u32,
413 src: []const u32,
414 width: u32,
415 height: u32,
416 radius: u32,
417 sigma: f32,
418 ) !void {
419 const pixels = try validateImageSlices(dst, src, width, height);
420 const taps = try blurTapCount(radius);
421 try self.ensureStorage(.{
422 .dst_pixels = pixels,
423 .src_pixels = pixels,
424 .scratch_pixels = pixels,
425 .weight_taps = taps,
426 });
427
428 const dst_buffer = self.buffers.dst.?.handle;
429 const scratch_buffer = self.buffers.scratch.?.handle;
430 const src_buffer = self.buffers.src.?.handle;
431 const weights_buffer = try self.buffers.ensureWeightsData(self.handle, radius, sigma);
432
433 try self.handle.writeBuffer(.{
434 .handle = src_buffer,
435 .bytes = std.mem.sliceAsBytes(src[0..pixels]),
436 });
437 try self.queueBlurPass(scratch_buffer, src_buffer, weights_buffer, width, height, radius, .horizontal);
438 errdefer self.handle.synchronize(.{ .scope = .device }) catch {};
439 try self.queueBlurPass(dst_buffer, scratch_buffer, weights_buffer, width, height, radius, .vertical);
440 try self.handle.synchronize(.{ .scope = .device });
441 try self.buffers.readPixels(self.handle, dst_buffer, dst, pixels);
442 }
443
444 fn ensureStorage(self: *Processor, demand: Limits) !void {
445 if (self.buffers.capacity) |capacity| {
446 if (capacity.admits(demand)) return;
447 }
448 const next_limits = if (self.buffers.capacity) |capacity|
449 capacity.limits.merged(demand)
450 else
451 demand;
452 const next = try ImageBuffers.init(self.allocator, self.handle, next_limits);
453 const replacing = self.buffers.capacity != null;
454 self.buffers.deinit(self.allocator, self.handle);
455 self.buffers = next;
456 if (replacing) self.storage_replacements += 1;
457 }
458
459 pub fn launchBlurPass(
460 self: *Processor,
461 dst: gpu.BufferHandle,
462 src: gpu.BufferHandle,
463 weights: gpu.BufferHandle,
464 width: u32,
465 height: u32,
466 radius: u32,
467 axis: Axis,
468 ) !void {
469 try self.queueBlurPass(dst, src, weights, width, height, radius, axis);
470 try self.handle.synchronize(.{ .scope = .device });
471 }
472
473 fn queueBlurPass(
474 self: *Processor,
475 dst: gpu.BufferHandle,
476 src: gpu.BufferHandle,
477 weights: gpu.BufferHandle,
478 width: u32,
479 height: u32,
480 radius: u32,
481 axis: Axis,
482 ) !void {
483 const pixels = try pixelCount(width, height);
484 const taps = try blurTapCount(radius);
485 try expectBufferSize(dst, pixels, @sizeOf(u32));
486 try expectBufferSize(src, pixels, @sizeOf(u32));
487 try expectBufferSize(weights, taps, @sizeOf(f32));
488
489 const runtime_arguments = try blurRuntimeArguments(width, height);
490 const bindings = [_]gpu.BufferBinding{
491 bufferBinding(dst, .read_write),
492 bufferBinding(src, .read_only),
493 bufferBinding(weights, .read_only),
494 };
495 try self.queueCachedKernel(.{ .blur_pass = .{
496 .width = width,
497 .height = height,
498 .radius = radius,
499 .axis = axis,
500 .threads = image_library.imageThreadsForExtents(width, height),
501 } }, bindings[0..], runtime_arguments[0..], "gui/paint/image/blur-pass");
502 }
503
504 pub fn launchResizeBilinear(
505 self: *Processor,
506 dst: gpu.BufferHandle,
507 src: gpu.BufferHandle,
508 dst_width: u32,
509 dst_height: u32,
510 src_width: u32,
511 src_height: u32,
512 ) !void {
513 try expectBufferSize(dst, try pixelCount(dst_width, dst_height), @sizeOf(u32));
514 try expectBufferSize(src, try pixelCount(src_width, src_height), @sizeOf(u32));
515
516 const runtime_arguments = try resizeRuntimeArguments(dst_width, dst_height, src_width, src_height);
517 const bindings = [_]gpu.BufferBinding{
518 bufferBinding(dst, .read_write),
519 bufferBinding(src, .read_only),
520 };
521 try self.launchCachedKernel(.{ .resize_bilinear = .{
522 .dst_width = dst_width,
523 .dst_height = dst_height,
524 .src_width = src_width,
525 .src_height = src_height,
526 .threads = image_library.imageThreadsForExtents(dst_width, dst_height),
527 } }, bindings[0..], runtime_arguments[0..], "gui/paint/image/resize-bilinear");
528 }
529
530 fn launchCachedKernel(
531 self: *Processor,
532 key: ImageKernelKey,
533 bindings: []const gpu.BufferBinding,
534 runtime_arguments: []const choir_abi.ScalarArgument,
535 diagnostic_id: []const u8,
536 ) !void {
537 try self.queueCachedKernel(key, bindings, runtime_arguments, diagnostic_id);
538 try self.handle.synchronize(.{ .scope = .device });
539 }
540
541 fn queueCachedKernel(
542 self: *Processor,
543 key: ImageKernelKey,
544 bindings: []const gpu.BufferBinding,
545 runtime_arguments: []const choir_abi.ScalarArgument,
546 diagnostic_id: []const u8,
547 ) !void {
548 const cached = try self.cachedKernel(key, diagnostic_id);
549 const entry = cached.call_artifact.entry();
550 if (entry.element_count_argument != .none) return error.UnsupportedImageOperation;
551 const expected_runtime_scalar_count: usize = @intCast(entry.runtime_scalar_argument_count);
552 if (runtime_arguments.len != expected_runtime_scalar_count) return error.LaunchArgumentMismatch;
553
554 const geometry = try accy.kernel.kernelCallEntryLaunchGeometry(entry, runtime_arguments);
555 const scalars = try imageLaunchScalars(entry, cached.artifact.format, geometry, runtime_arguments);
556
557 try self.handle.launch(.{
558 .artifact = &cached.artifact,
559 .loaded_artifact = cached.loaded,
560 .buffers = bindings,
561 .scalar_arguments = scalars.slice(),
562 .geometry = geometry,
563 .diagnostic_id = diagnostic_id,
564 });
565 }
566
567 fn cachedKernel(self: *Processor, key: ImageKernelKey, diagnostic_id: []const u8) !*CachedImageKernel {
568 for (self.kernels.items) |*cached| {
569 if (cached.key.eql(key)) return cached;
570 }
571
572 var descriptor = (try library.selectOwned(self.allocator, .{ .image = key.query() })) orelse return error.UnsupportedImageOperation;
573 defer descriptor.deinit();
574
575 var call_artifact = try library.createOwnedKernelCallArtifact(self.allocator, self.handle, descriptor, .{
576 .limits = accy.kernel.Limits.standard,
577 .format = self.format,
578 });
579 errdefer call_artifact.deinit();
580
581 var artifact = try accy.kernel.createBackendArtifactFromKernelCallEntry(
582 self.allocator,
583 self.handle,
584 call_artifact.entry(),
585 diagnostic_id,
586 );
587 errdefer artifact.deinit();
588
589 const loaded = try self.handle.loadArtifact(&artifact);
590 errdefer self.handle.destroyObject(loaded.id);
591
592 try self.kernels.append(self.allocator, .{
593 .key = key,
594 .call_artifact = call_artifact,
595 .artifact = artifact,
596 .loaded = loaded,
597 });
598 return &self.kernels.items[self.kernels.items.len - 1];
599 }
600
601 fn useShadowAlphaCache(self: *Processor, key: ShadowAlphaKey, dst: []u32) bool {
602 if (self.shadow_alpha) |*cached| {
603 if (cached.matches(key, dst.len)) {
604 @memcpy(dst, cached.pixels);
605 return true;
606 }
607 }
608 return false;
609 }
610
611 fn storeShadowAlpha(self: *Processor, key: ShadowAlphaKey, pixels: []const u32) !void {
612 if (self.shadow_alpha) |*cached| {
613 if (cached.pixels.len == pixels.len) {
614 cached.key = key;
615 @memcpy(cached.pixels, pixels);
616 return;
617 }
618 cached.deinit(self.allocator);
619 self.shadow_alpha = null;
620 }
621 const copy = try self.allocator.dupe(u32, pixels);
622 self.shadow_alpha = .{
623 .key = key,
624 .pixels = copy,
625 };
626 }
627 };
628
629 const CachedShadowAlpha = struct {
630 key: ShadowAlphaKey,
631 pixels: []u32,
632
633 fn deinit(self: *CachedShadowAlpha, allocator: Allocator) void {
634 allocator.free(self.pixels);
635 self.* = undefined;
636 }
637
638 fn matches(self: *const CachedShadowAlpha, key: ShadowAlphaKey, pixel_count: usize) bool {
639 return self.pixels.len == pixel_count and self.key.eql(key);
640 }
641 };
642
643 const ImageKernelKey = union(enum) {
644 blur_pass: struct {
645 width: u32,
646 height: u32,
647 radius: u32,
648 axis: Axis,
649 threads: ImageThreads,
650 },
651 resize_bilinear: struct {
652 dst_width: u32,
653 dst_height: u32,
654 src_width: u32,
655 src_height: u32,
656 threads: ImageThreads,
657 },
658
659 fn eql(self: ImageKernelKey, other: ImageKernelKey) bool {
660 return switch (self) {
661 .blur_pass => |lhs| switch (other) {
662 .blur_pass => |rhs| lhs.width == rhs.width and
663 lhs.height == rhs.height and
664 lhs.radius == rhs.radius and
665 lhs.axis == rhs.axis and
666 threadsEql(lhs.threads, rhs.threads),
667 else => false,
668 },
669 .resize_bilinear => |lhs| switch (other) {
670 .resize_bilinear => |rhs| lhs.dst_width == rhs.dst_width and
671 lhs.dst_height == rhs.dst_height and
672 lhs.src_width == rhs.src_width and
673 lhs.src_height == rhs.src_height and
674 threadsEql(lhs.threads, rhs.threads),
675 else => false,
676 },
677 };
678 }
679
680 fn query(self: ImageKernelKey) library.ImageQuery {
681 return switch (self) {
682 .blur_pass => |key| .{
683 .dtype = .u32,
684 .kind = .{ .blur_pass = .{ .radius = key.radius, .axis = key.axis } },
685 .width = key.width,
686 .height = key.height,
687 .schedule = .{ .thread_blocks = key.threads },
688 },
689 .resize_bilinear => |key| .{
690 .dtype = .u32,
691 .kind = .{ .resize_bilinear = .{ .src_width = key.src_width, .src_height = key.src_height } },
692 .width = key.dst_width,
693 .height = key.dst_height,
694 .schedule = .{ .thread_blocks = key.threads },
695 },
696 };
697 }
698 };
699
700 const CachedImageKernel = struct {
701 key: ImageKernelKey,
702 call_artifact: accy.kernel.OwnedKernelCallArtifact,
703 artifact: gpu.KernelArtifact,
704 loaded: gpu.LoadedArtifact,
705
706 fn deinit(self: *CachedImageKernel, handle: gpu.BackendHandle) void {
707 handle.destroyObject(self.loaded.id);
708 self.artifact.deinit();
709 self.call_artifact.deinit();
710 self.* = undefined;
711 }
712 };
713
714 pub const ImageHostStorage = struct {
715 pub const Limits = ImageLimits;
716 pub const Capacity = ImageCapacity;
717
718 pub const claim: alloc_phase.capacity.Declaration = .{
719 .source = .{
720 .id = "gui.paint_image_host_storage",
721 .kind = .phase_static,
722 .limit_source = .caller,
723 .storage = .{
724 .covered = &.{
725 .{
726 .id = "caller_sized_image_readback_staging",
727 .lifetime = .steady,
728 .detail = "caller-sized image readback staging",
729 },
730 .{
731 .id = "caller_sized_gaussian_weight_staging",
732 .lifetime = .steady,
733 .detail = "caller-sized Gaussian weight staging",
734 },
735 },
736 .excluded = &.{
737 "destination source scratch and weight backend buffers and their foreign allocation",
738 "caller-owned source destination and allocating result pixels",
739 "shadow mask output expansion and retained alpha cache pixels",
740 "compiled loaded and indexed image-family kernel cache artifacts",
741 "transactional complete-epoch replacement by Processor",
742 },
743 },
744 .capacity = .{
745 .inputs = &.{
746 alloc_phase.capacity.bindInput(ImageLimits, "dst_pixels", "dst_pixels"),
747 alloc_phase.capacity.bindInput(ImageLimits, "weight_taps", "weight_taps"),
748 },
749 .type_selectors = &.{},
750 .nodes = &.{
751 .{ .input = 0 },
752 .{ .input = 1 },
753 .{ .add = .{ .left = 0, .right = 1 } },
754 .{ .scale = .{ .node = 2, .coefficient = .{ .literal = 4 } } },
755 },
756 .assertions = &.{.{
757 .scope = .closure_total,
758 .measure = .retained,
759 .relation = .exact,
760 .expression = 3,
761 }},
762 },
763 .overload = .{
764 .kind = .reject_before_seal,
765 .detail = "checked capacity derivation and allocation failure reject before host storage activation; Processor may acquire a separate larger epoch",
766 },
767 .risks = .{
768 .transitive = .{
769 .status = .open,
770 .detail = "weight generation readback and backend transfer helpers are exercised but lack a machine-checked call-graph closure certificate",
771 },
772 .foreign = .{
773 .status = .open,
774 .detail = "backend buffer acquisition is capacity-accounted by Processor but remains outside this host-storage claim",
775 },
776 },
777 .obligations = &.{
778 .{ .key = "gui_paint_image_capacity_capacity_model", .role = .capacity_model },
779 .{ .key = "gui_paint_image_capacity_overload", .role = .overload },
780 .{ .key = "gui_paint_image_acquisition", .role = .custom },
781 .{ .key = "gui_paint_image_host_oom", .role = .overload },
782 .{ .key = "gui_paint_image_boundary", .role = .custom },
783 .{ .key = "gui_paint_image_atomic", .role = .custom },
784 .{ .key = "gui_paint_image_steady", .role = .custom },
785 .{ .key = "gui_paint_image_scalars", .role = .custom },
786 },
787 },
788 .bindings = .{
789 .owner = @This(),
790 .seal = .{
791 .family = alloc_phase.capacity.selector(@This().activate),
792 .premise = .{
793 .class = .checked_semantic_fact,
794 .authority = .checker,
795 },
796 },
797 .teardown = .{
798 .family = alloc_phase.capacity.selector(@This().deinit),
799 .premise = .{
800 .class = .checked_semantic_fact,
801 .authority = .checker,
802 },
803 },
804 },
805 };
806
807 phase: alloc_phase.capacity.Phase,
808 capacity: ImageCapacity,
809 limits: ImageLimits,
810 bytes: []align(@alignOf(u32)) u8,
811
812 pub fn init(allocator: Allocator, limits: ImageLimits) !ImageHostStorage {
813 const capacity = try ImageCapacity.derive(limits);
814 const bytes = if (capacity.host_storage_bytes == 0)
815 @as([]align(@alignOf(u32)) u8, &.{})
816 else
817 try allocator.alignedAlloc(
818 u8,
819 .fromByteUnits(@alignOf(u32)),
820 capacity.host_storage_bytes,
821 );
822 return .{
823 .phase = .initialization,
824 .capacity = capacity,
825 .limits = limits,
826 .bytes = bytes,
827 };
828 }
829
830 pub fn activate(self: *ImageHostStorage) void {
831 std.debug.assert(self.phase == .initialization);
832 std.debug.assert(std.meta.eql(self.capacity.limits, self.limits));
833 std.debug.assert(self.bytes.len == self.capacity.host_storage_bytes);
834 self.phase = .steady;
835 }
836
837 pub fn deinit(self: *ImageHostStorage, allocator: Allocator) void {
838 std.debug.assert(self.phase != .teardown);
839 self.phase = .teardown;
840 allocator.free(self.bytes);
841 self.* = undefined;
842 }
843 };
844
845 comptime {
846 alloc_phase.capacity.requireAllocatorExactOwnerShape(ImageHostStorage);
847 }
848
849 const ImageBuffers = struct {
850 capacity: ?Capacity = null,
851 host: ?ImageHostStorage = null,
852 dst: ?RetainedDeviceBuffer = null,
853 src: ?RetainedDeviceBuffer = null,
854 scratch: ?RetainedDeviceBuffer = null,
855 weights: ?RetainedDeviceBuffer = null,
856 weights_valid: bool = false,
857 weights_radius: u32 = 0,
858 weights_sigma: f32 = 0,
859 readback: []u32 = &.{},
860 weight_staging: []f32 = &.{},
861
862 fn init(allocator: Allocator, handle: gpu.BackendHandle, limits: Limits) !ImageBuffers {
863 var host_storage = try ImageHostStorage.init(allocator, limits);
864 errdefer host_storage.deinit(allocator);
865 const capacity = host_storage.capacity;
866 var host = ImageHostCursor{ .bytes = host_storage.bytes };
867 const readback = host.take(u32, limits.dst_pixels);
868 const weight_staging = host.take(f32, limits.weight_taps);
869 std.debug.assert(host.offset == host_storage.bytes.len);
870
871 var dst: ?RetainedDeviceBuffer = null;
872 errdefer if (dst) |*buffer| buffer.deinit(handle);
873 var src: ?RetainedDeviceBuffer = null;
874 errdefer if (src) |*buffer| buffer.deinit(handle);
875 var scratch: ?RetainedDeviceBuffer = null;
876 errdefer if (scratch) |*buffer| buffer.deinit(handle);
877 var weights: ?RetainedDeviceBuffer = null;
878 errdefer if (weights) |*buffer| buffer.deinit(handle);
879 if (limits.dst_pixels != 0) dst = try retainedDeviceBuffer(handle, u32, .u32, limits.dst_pixels);
880 if (limits.src_pixels != 0) src = try retainedDeviceBuffer(handle, u32, .u32, limits.src_pixels);
881 if (limits.scratch_pixels != 0) scratch = try retainedDeviceBuffer(handle, u32, .u32, limits.scratch_pixels);
882 if (limits.weight_taps != 0) weights = try retainedDeviceBuffer(handle, f32, .f32, limits.weight_taps);
883 host_storage.activate();
884 return .{
885 .capacity = capacity,
886 .host = host_storage,
887 .dst = dst,
888 .src = src,
889 .scratch = scratch,
890 .weights = weights,
891 .readback = readback,
892 .weight_staging = weight_staging,
893 };
894 }
895
896 fn deinit(self: *ImageBuffers, allocator: Allocator, handle: gpu.BackendHandle) void {
897 if (self.dst) |*buffer| buffer.deinit(handle);
898 if (self.src) |*buffer| buffer.deinit(handle);
899 if (self.scratch) |*buffer| buffer.deinit(handle);
900 if (self.weights) |*buffer| buffer.deinit(handle);
901 if (self.host) |*host| host.deinit(allocator);
902 self.* = .{};
903 }
904
905 fn ensureWeightsData(
906 self: *ImageBuffers,
907 handle: gpu.BackendHandle,
908 radius: u32,
909 sigma: f32,
910 ) !gpu.BufferHandle {
911 _ = try blurTapCount(radius);
912 const weights = self.weights orelse return error.BufferTooSmall;
913 if (self.weights_valid and self.weights_radius == radius and self.weights_sigma == sigma) {
914 return weights.handle;
915 }
916
917 self.weights_valid = false;
918 const weights_data = try image_library.gaussianWeights(self.weight_staging, radius, sigma);
919 try handle.writeBuffer(.{
920 .handle = weights.handle,
921 .bytes = std.mem.sliceAsBytes(weights_data),
922 });
923 self.weights_valid = true;
924 self.weights_radius = radius;
925 self.weights_sigma = sigma;
926 return weights.handle;
927 }
928
929 fn readPixels(
930 self: *ImageBuffers,
931 handle: gpu.BackendHandle,
932 source: gpu.BufferHandle,
933 dst: []u32,
934 count: usize,
935 ) !void {
936 const retained_count = source.byte_size / @sizeOf(u32);
937 if (self.readback.len < retained_count) return error.BufferTooSmall;
938 try handle.readBuffer(.{
939 .handle = source,
940 .bytes = std.mem.sliceAsBytes(self.readback[0..retained_count]),
941 });
942 @memcpy(dst[0..count], self.readback[0..count]);
943 }
944 };
945
946 const ImageHostCursor = struct {
947 bytes: []align(@alignOf(u32)) u8,
948 offset: usize = 0,
949
950 fn take(self: *ImageHostCursor, comptime T: type, count: usize) []T {
951 comptime std.debug.assert(@sizeOf(T) == @sizeOf(u32));
952 comptime std.debug.assert(@alignOf(T) <= @alignOf(u32));
953 const byte_count = count * @sizeOf(T);
954 std.debug.assert(self.offset + byte_count <= self.bytes.len);
955 const pointer: [*]T = @ptrCast(@alignCast(self.bytes.ptr + self.offset));
956 self.offset += byte_count;
957 return pointer[0..count];
958 }
959 };
960
961 const RetainedDeviceBuffer = struct {
962 handle: gpu.BufferHandle,
963 element_count: usize,
964
965 fn deinit(self: *RetainedDeviceBuffer, handle: gpu.BackendHandle) void {
966 handle.destroyObject(self.handle.id);
967 self.* = undefined;
968 }
969 };
970
971 fn retainedDeviceBuffer(
972 handle: gpu.BackendHandle,
973 comptime T: type,
974 dtype: choir_abi.DType,
975 count: usize,
976 ) !RetainedDeviceBuffer {
977 const next = try allocateDeviceBuffer(handle, T, dtype, count);
978 return .{
979 .handle = next,
980 .element_count = count,
981 };
982 }
983
984 fn threadsEql(lhs: ImageThreads, rhs: ImageThreads) bool {
985 return lhs.x == rhs.x and lhs.y == rhs.y;
986 }
987
988 fn countShadows(commands: []const command.Command) usize {
989 var count: usize = 0;
990 for (commands) |paint| {
991 if (paint.kind == .shadow) count += 1;
992 }
993 return count;
994 }
995
996 const ShadowPlan = struct {
997 command: command.Command,
998 frame: ShadowFrame,
999 pixels: usize,
1000 width: u32,
1001 height: u32,
1002 blur_radius: u32,
1003 sigma: f32,
1004 key: ShadowAlphaKey,
1005
1006 fn shadow(self: ShadowPlan, pixels: []u32) Shadow {
1007 return .{
1008 .command = self.command,
1009 .image = .{
1010 .width = self.width,
1011 .height = self.height,
1012 .pixels = pixels,
1013 },
1014 .pixels = pixels,
1015 };
1016 }
1017 };
1018
1019 const PreparedShadow = struct {
1020 plan: ShadowPlan,
1021 mask: []u32,
1022
1023 fn deinitMask(self: *const PreparedShadow, allocator: Allocator) void {
1024 allocator.free(self.mask);
1025 }
1026
1027 fn deinit(self: PreparedShadow, allocator: Allocator) void {
1028 allocator.free(self.mask);
1029 }
1030
1031 fn shadow(self: PreparedShadow, pixels: []u32) Shadow {
1032 return self.plan.shadow(pixels);
1033 }
1034 };
1035
1036 fn defaultFormat(handle: gpu.BackendHandle) !gpu.ArtifactFormat {
1037 const kind = handle.backendKind() orelse (try handle.queryCapabilities()).identity.backend;
1038 return switch (kind) {
1039 .cuda => .cuda_ptx,
1040 .vulkan => .vulkan_spirv,
1041 .metal => .metal_msl,
1042 .webgpu => .webgpu_wgsl,
1043 .cpu => .cpu_object,
1044 .wasm => .webassembly_module,
1045 .external => error.UnsupportedArtifactFormat,
1046 };
1047 }
1048
1049 fn planShadow(paint: command.Command, options: ShadowOptions) !ShadowPlan {
1050 if (paint.kind != .shadow) return error.InvalidShadowCommand;
1051 const blur_radius = try shadowBlurRadius(paint.width);
1052 const sigma = try shadowSigma(blur_radius, options.sigma);
1053 const frame = try shadowFrame(paint, blur_radius);
1054 const pixels = try pixelCount(frame.width, frame.height);
1055 return .{
1056 .command = shadowImageCommand(paint, frame, options.image_index),
1057 .frame = frame,
1058 .pixels = pixels,
1059 .width = frame.width,
1060 .height = frame.height,
1061 .blur_radius = blur_radius,
1062 .sigma = sigma,
1063 .key = ShadowAlphaKey.init(paint, frame, blur_radius, sigma),
1064 };
1065 }
1066
1067 fn prepareShadow(allocator: Allocator, paint: command.Command, options: ShadowOptions) !PreparedShadow {
1068 const plan = try planShadow(paint, options);
1069 const mask = try allocator.alloc(u32, plan.pixels);
1070 errdefer allocator.free(mask);
1071 rasterShadowMask(mask, plan.frame, paint);
1072 return .{
1073 .plan = plan,
1074 .mask = mask,
1075 };
1076 }
1077
1078 const ShadowFrame = struct {
1079 rect: Rect,
1080 width: u32,
1081 height: u32,
1082 };
1083
1084 const ShadowAlphaKey = struct {
1085 paint_rect: Rect,
1086 frame_rect: Rect,
1087 radius: f32,
1088 alpha: u8,
1089 width: u32,
1090 height: u32,
1091 blur_radius: u32,
1092 sigma: f32,
1093
1094 fn init(paint: command.Command, frame: ShadowFrame, blur_radius: u32, sigma: f32) ShadowAlphaKey {
1095 return .{
1096 .paint_rect = paint.rect,
1097 .frame_rect = frame.rect,
1098 .radius = paint.radius,
1099 .alpha = paint.color.a,
1100 .width = frame.width,
1101 .height = frame.height,
1102 .blur_radius = blur_radius,
1103 .sigma = sigma,
1104 };
1105 }
1106
1107 fn eql(self: ShadowAlphaKey, other: ShadowAlphaKey) bool {
1108 return rectEqual(self.paint_rect, other.paint_rect) and
1109 rectEqual(self.frame_rect, other.frame_rect) and
1110 self.radius == other.radius and
1111 self.alpha == other.alpha and
1112 self.width == other.width and
1113 self.height == other.height and
1114 self.blur_radius == other.blur_radius and
1115 self.sigma == other.sigma;
1116 }
1117 };
1118
1119 fn rectEqual(left: Rect, right: Rect) bool {
1120 return left.x == right.x and
1121 left.y == right.y and
1122 left.width == right.width and
1123 left.height == right.height;
1124 }
1125
1126 fn shadowFrame(paint: command.Command, blur_radius: u32) !ShadowFrame {
1127 if (!shadowRectFinite(paint.rect) or !shadowRectFinite(paint.clip)) return error.InvalidShadowCommand;
1128 if (paint.rect.width <= 0 or paint.rect.height <= 0) return error.InvalidShadowCommand;
1129 const margin: f32 = @floatFromInt(blur_radius);
1130 const rect = Rect{
1131 .x = paint.rect.x - margin,
1132 .y = paint.rect.y - margin,
1133 .width = paint.rect.width + margin * 2,
1134 .height = paint.rect.height + margin * 2,
1135 };
1136 return .{
1137 .rect = rect,
1138 .width = try extentFromFloat(rect.width),
1139 .height = try extentFromFloat(rect.height),
1140 };
1141 }
1142
1143 fn shadowRectFinite(rect: Rect) bool {
1144 return std.math.isFinite(rect.x) and
1145 std.math.isFinite(rect.y) and
1146 std.math.isFinite(rect.width) and
1147 std.math.isFinite(rect.height);
1148 }
1149
1150 fn extentFromFloat(value: f32) !u32 {
1151 if (!std.math.isFinite(value) or value <= 0) return error.InvalidImage;
1152 const ceiled = @ceil(value);
1153 if (ceiled > @as(f32, @floatFromInt(std.math.maxInt(u32)))) return error.DimensionsTooLarge;
1154 return @intFromFloat(ceiled);
1155 }
1156
1157 fn shadowBlurRadius(width: f32) !u32 {
1158 if (std.math.isNan(width)) return error.InvalidShadowCommand;
1159 if (width <= 0) return 0;
1160 if (!std.math.isFinite(width)) return error.InvalidShadowCommand;
1161 const ceiled = @ceil(width);
1162 if (ceiled > @as(f32, @floatFromInt(image_library.blur_radius_max))) return error.UnsupportedImageOperation;
1163 return @intFromFloat(ceiled);
1164 }
1165
1166 fn shadowSigma(radius: u32, override: ?f32) !f32 {
1167 if (override) |value| {
1168 if (!(value > 0) or !std.math.isFinite(value)) return error.UnsupportedImageOperation;
1169 return value;
1170 }
1171 if (radius == 0) return 1;
1172 return @max(@as(f32, @floatFromInt(radius)) * 0.5, @as(f32, 0.5));
1173 }
1174
1175 fn rasterShadowMask(dst: []u32, frame: ShadowFrame, paint: command.Command) void {
1176 var y: u32 = 0;
1177 while (y < frame.height) : (y += 1) {
1178 var x: u32 = 0;
1179 while (x < frame.width) : (x += 1) {
1180 var coverage: u32 = 0;
1181 inline for (.{ -0.25, 0.25 }) |dy| {
1182 inline for (.{ -0.25, 0.25 }) |dx| {
1183 const px = frame.rect.x + @as(f32, @floatFromInt(x)) + 0.5 + dx;
1184 const py = frame.rect.y + @as(f32, @floatFromInt(y)) + 0.5 + dy;
1185 if (cpu.geometry.insideRounded(paint.rect, @max(paint.radius, 0), px, py)) coverage += 1;
1186 }
1187 }
1188 const alpha = cpu.pixel.coverageAlpha(paint.color.a, coverage);
1189 dst[@as(usize, y) * frame.width + x] = cpu.pixel.packRgba(.{ .a = alpha });
1190 }
1191 }
1192 }
1193
1194 fn tintShadowPixels(pixels: []u32, color: Color) void {
1195 for (pixels) |*pixel_value| {
1196 const alpha: u8 = @truncate(pixel_value.* >> 24);
1197 pixel_value.* = cpu.pixel.packRgba(.{
1198 .r = color.r,
1199 .g = color.g,
1200 .b = color.b,
1201 .a = alpha,
1202 });
1203 }
1204 }
1205
1206 fn shadowImageCommand(paint: command.Command, frame: ShadowFrame, image_index: u32) command.Command {
1207 return .{
1208 .kind = .image,
1209 .rect = .{
1210 .x = frame.rect.x,
1211 .y = frame.rect.y,
1212 .width = @floatFromInt(frame.width),
1213 .height = @floatFromInt(frame.height),
1214 },
1215 .clip = paint.clip,
1216 .color = .{ .r = 255, .g = 255, .b = 255, .a = 255 },
1217 .image_index = image_index,
1218 .order = paint.order,
1219 };
1220 }
1221
1222 fn validateImageSlices(dst: []u32, src: []const u32, width: u32, height: u32) !usize {
1223 const source = command.Image{ .width = width, .height = height, .pixels = src };
1224 try source.validate();
1225 const pixels = try pixelCount(width, height);
1226 if (dst.len < pixels) return error.BufferTooSmall;
1227 return pixels;
1228 }
1229
1230 fn pixelCount(width: u32, height: u32) !usize {
1231 if (width == 0 or height == 0) return error.InvalidImage;
1232 return std.math.mul(usize, @as(usize, width), @as(usize, height)) catch return error.DimensionsTooLarge;
1233 }
1234
1235 const ImageExtent = struct {
1236 width: u32,
1237 height: u32,
1238 };
1239
1240 fn thumbnailExtent(
1241 src_width: u32,
1242 src_height: u32,
1243 max_width: u32,
1244 max_height: u32,
1245 ) !ImageExtent {
1246 if (src_width == 0 or src_height == 0 or max_width == 0 or max_height == 0) return error.InvalidImage;
1247 if (src_width <= max_width and src_height <= max_height) {
1248 return .{ .width = src_width, .height = src_height };
1249 }
1250 const width_limited = @as(u64, max_width) * @as(u64, src_height) <= @as(u64, max_height) * @as(u64, src_width);
1251 if (width_limited) {
1252 return .{
1253 .width = max_width,
1254 .height = scaledExtent(src_height, max_width, src_width),
1255 };
1256 }
1257 return .{
1258 .width = scaledExtent(src_width, max_height, src_height),
1259 .height = max_height,
1260 };
1261 }
1262
1263 fn scaledExtent(value: u32, numerator: u32, denominator: u32) u32 {
1264 const scaled = (@as(u64, value) * @as(u64, numerator)) / @as(u64, denominator);
1265 return @intCast(@max(scaled, 1));
1266 }
1267
1268 fn blurTapCount(radius: u32) !usize {
1269 if (radius == 0 or radius > image_library.blur_radius_max) return error.UnsupportedImageOperation;
1270 return @intCast(radius * 2 + 1);
1271 }
1272
1273 fn blurRuntimeArguments(width: u32, height: u32) ![2]choir_abi.ScalarArgument {
1274 if (width == 0 or height == 0) return error.UnsupportedImageOperation;
1275 return .{
1276 .{ .u32 = width },
1277 .{ .u32 = height },
1278 };
1279 }
1280
1281 fn resizeRuntimeArguments(dst_width: u32, dst_height: u32, src_width: u32, src_height: u32) ![7]choir_abi.ScalarArgument {
1282 if (dst_width == 0 or dst_height == 0 or src_width == 0 or src_height == 0) return error.UnsupportedImageOperation;
1283 return .{
1284 .{ .u32 = dst_width },
1285 .{ .u32 = dst_height },
1286 .{ .u32 = src_width },
1287 .{ .f32 = image_library.resizeScale(src_width, dst_width) },
1288 .{ .f32 = image_library.resizeScale(src_height, dst_height) },
1289 .{ .f32 = @floatFromInt(src_width - 1) },
1290 .{ .f32 = @floatFromInt(src_height - 1) },
1291 };
1292 }
1293
1294 fn expectBufferSize(handle: gpu.BufferHandle, count: usize, element_size: usize) !void {
1295 const byte_size = std.math.mul(usize, count, element_size) catch return error.BufferTooLarge;
1296 if (handle.byte_size < byte_size) return error.BufferTooSmall;
1297 }
1298
1299 fn allocateDeviceBuffer(handle: gpu.BackendHandle, comptime T: type, dtype: choir_abi.DType, count: usize) !gpu.BufferHandle {
1300 const byte_size = std.math.mul(usize, count, @sizeOf(T)) catch return error.BufferTooLarge;
1301 return handle.allocateBuffer(.{
1302 .byte_size = byte_size,
1303 .alignment = 256,
1304 .dtype = dtype,
1305 .element_count = std.math.cast(u64, count) orelse return error.BufferTooLarge,
1306 });
1307 }
1308
1309 fn bufferBinding(handle: gpu.BufferHandle, access: gpu.BufferAccess) gpu.BufferBinding {
1310 return .{
1311 .handle = handle,
1312 .access = access,
1313 .ownership = handle.ownership,
1314 .byte_size = handle.byte_size,
1315 };
1316 }
1317
1318 fn testPixel(seed: usize) u32 {
1319 var value: u32 = @truncate(seed *% 2654435761);
1320 value ^= value >> 13;
1321 value *%= 0x5bd1e995;
1322 value ^= value >> 15;
1323 return value;
1324 }
1325
1326 fn referenceShadowAlloc(allocator: Allocator, paint: command.Command, options: ShadowOptions) !Shadow {
1327 const prepared = try prepareShadow(allocator, paint, options);
1328 var prepared_owned = true;
1329 defer if (prepared_owned) prepared.deinit(allocator);
1330
1331 const pixels = try allocator.alloc(u32, prepared.plan.pixels);
1332 errdefer allocator.free(pixels);
1333 if (prepared.plan.blur_radius > 0) {
1334 const scratch = try allocator.alloc(u32, prepared.plan.pixels);
1335 defer allocator.free(scratch);
1336 const weights = try image_library.gaussianWeightsAlloc(allocator, prepared.plan.blur_radius, prepared.plan.sigma);
1337 defer allocator.free(weights);
1338 image_library.referenceBlurPass(scratch, prepared.mask, weights, prepared.plan.width, prepared.plan.height, .horizontal);
1339 image_library.referenceBlurPass(pixels, scratch, weights, prepared.plan.width, prepared.plan.height, .vertical);
1340 } else {
1341 @memcpy(pixels, prepared.mask);
1342 }
1343 tintShadowPixels(pixels, paint.color);
1344 const result = prepared.shadow(pixels);
1345 prepared_owned = false;
1346 prepared.deinitMask(allocator);
1347 return result;
1348 }
1349
1350 test "paint image capacity matches an independent host and device byte model" {
1351 comptime {
1352 @stardustClaim(
1353 @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_capacity_capacity_model"),
1354 null,
1355 null,
1356 null,
1357 null,
1358 null,
1359 null,
1360 );
1361 }
1362 comptime {
1363 @stardustClaim(
1364 @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_capacity_overload"),
1365 null,
1366 null,
1367 null,
1368 null,
1369 null,
1370 null,
1371 );
1372 }
1373
1374 const cases = [_]Limits{
1375 .{},
1376 .{ .dst_pixels = 4096, .src_pixels = 2048 },
1377 .{ .dst_pixels = 257, .src_pixels = 129, .scratch_pixels = 257, .weight_taps = 31 },
1378 };
1379 for (cases) |limits| {
1380 const capacity = try Capacity.derive(limits);
1381 const host_elements = @as(u128, limits.dst_pixels) + limits.weight_taps;
1382 const device_elements = @as(u128, limits.dst_pixels) + limits.src_pixels + limits.scratch_pixels + limits.weight_taps;
1383 const device_buffer_count = @as(usize, @intFromBool(limits.dst_pixels != 0)) +
1384 @as(usize, @intFromBool(limits.src_pixels != 0)) +
1385 @as(usize, @intFromBool(limits.scratch_pixels != 0)) +
1386 @as(usize, @intFromBool(limits.weight_taps != 0));
1387 try std.testing.expectEqual(@as(usize, @intCast(host_elements * @sizeOf(u32))), capacity.host_storage_bytes);
1388 try std.testing.expectEqual(@as(usize, @intCast(device_elements * @sizeOf(u32))), capacity.device_storage_bytes);
1389 try std.testing.expectEqual(@as(usize, device_buffer_count), capacity.device_buffer_count);
1390 try std.testing.expectEqual(
1391 capacity.host_storage_bytes + capacity.device_storage_bytes,
1392 capacity.total_storage_bytes,
1393 );
1394 }
1395 }
1396
1397 test "paint image capacity rejects overflowing storage limits" {
1398 try std.testing.expectError(
1399 error.CapacityOverflow,
1400 Capacity.derive(.{
1401 .dst_pixels = std.math.maxInt(usize),
1402 .weight_taps = 1,
1403 }),
1404 );
1405 }
1406
1407 test "paint image host storage rejects initialization OOM and seals on retry" {
1408 comptime {
1409 @stardustClaim(
1410 @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_host_oom"),
1411 null,
1412 null,
1413 null,
1414 null,
1415 null,
1416 null,
1417 );
1418 }
1419
1420 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
1421 const limits = Limits{ .dst_pixels = 16, .weight_taps = 7 };
1422 failing.fail_index = failing.alloc_index;
1423 try std.testing.expectError(
1424 error.OutOfMemory,
1425 ImageHostStorage.init(failing.allocator(), limits),
1426 );
1427 try std.testing.expect(failing.has_induced_failure);
1428 failing.fail_index = std.math.maxInt(usize);
1429 var storage = try ImageHostStorage.init(failing.allocator(), limits);
1430 defer storage.deinit(failing.allocator());
1431 try std.testing.expectEqual(alloc_phase.capacity.Phase.initialization, storage.phase);
1432 storage.activate();
1433 try std.testing.expectEqual(alloc_phase.capacity.Phase.steady, storage.phase);
1434 try std.testing.expectEqual(storage.capacity.host_storage_bytes, storage.bytes.len);
1435 }
1436
1437 test "paint image Processor initial storage exposes the exact chosen capacity" {
1438 comptime {
1439 @stardustClaim(
1440 @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_acquisition"),
1441 null,
1442 null,
1443 null,
1444 null,
1445 null,
1446 null,
1447 );
1448 }
1449
1450 const allocator = std.testing.allocator;
1451 var state = gpu.recording.BackendState{
1452 .allocator = allocator,
1453 .kind = .vulkan,
1454 .format = .vulkan_spirv,
1455 };
1456 const limits = Limits{
1457 .dst_pixels = 64,
1458 .src_pixels = 32,
1459 .scratch_pixels = 64,
1460 .weight_taps = 7,
1461 };
1462 var processor = try Processor.init(allocator, state.handle(), .{
1463 .artifact_format = .vulkan_spirv,
1464 .initial_storage = limits,
1465 });
1466 defer processor.deinit();
1467 const status = processor.storageStatus();
1468 try std.testing.expectEqual(limits, status.limits.?);
1469 try std.testing.expectEqual(try Capacity.derive(limits), status.capacity.?);
1470 try std.testing.expectEqual(@as(usize, 0), status.replacements);
1471 try std.testing.expectEqual(status.capacity.?.host_storage_bytes, processor.buffers.host.?.bytes.len);
1472 try std.testing.expectEqual(status.capacity.?.device_buffer_count, state.buffer_allocate_count);
1473 }
1474
1475 test "paint image Processor replaces storage at max plus one and retains its high water mark" {
1476 comptime {
1477 @stardustClaim(
1478 @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_boundary"),
1479 null,
1480 null,
1481 null,
1482 null,
1483 null,
1484 null,
1485 );
1486 }
1487
1488 const allocator = std.testing.allocator;
1489 var state = gpu.recording.BackendState{
1490 .allocator = allocator,
1491 .kind = .vulkan,
1492 .format = .vulkan_spirv,
1493 };
1494 const limits = Limits{ .dst_pixels = 4, .src_pixels = 4 };
1495 var processor = try Processor.init(allocator, state.handle(), .{
1496 .artifact_format = .vulkan_spirv,
1497 .initial_storage = limits,
1498 });
1499 defer processor.deinit();
1500 var src = @as([4]u32, @splat(0));
1501 var dst = @as([6]u32, @splat(0));
1502 try processor.resizeBilinear(dst[0..4], src[0..], 2, 2, 2, 2);
1503 try std.testing.expectEqual(@as(usize, 0), processor.storageStatus().replacements);
1504 try processor.resizeBilinear(dst[0..], src[0..], 3, 2, 2, 2);
1505 const grown = processor.storageStatus();
1506 try std.testing.expectEqual(@as(usize, 1), grown.replacements);
1507 try std.testing.expectEqual(@as(usize, 6), grown.limits.?.dst_pixels);
1508 try std.testing.expectEqual(@as(usize, 4), grown.limits.?.src_pixels);
1509 try processor.resizeBilinear(dst[0..4], src[0..], 2, 2, 2, 2);
1510 try std.testing.expectEqual(grown, processor.storageStatus());
1511 }
1512
1513 test "paint image Processor failed storage replacement preserves the prior epoch" {
1514 comptime {
1515 @stardustClaim(
1516 @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_atomic"),
1517 null,
1518 null,
1519 null,
1520 null,
1521 null,
1522 null,
1523 );
1524 }
1525
1526 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
1527 var state = gpu.recording.BackendState{
1528 .allocator = failing.allocator(),
1529 .kind = .vulkan,
1530 .format = .vulkan_spirv,
1531 };
1532 var processor = try Processor.init(failing.allocator(), state.handle(), .{
1533 .artifact_format = .vulkan_spirv,
1534 .initial_storage = .{ .dst_pixels = 4, .src_pixels = 4 },
1535 });
1536 defer processor.deinit();
1537 const before = processor.storageStatus();
1538 const dst_id = processor.buffers.dst.?.handle.id;
1539 var src = @as([4]u32, @splat(0));
1540 var dst = @as([6]u32, @splat(0));
1541 failing.fail_index = failing.alloc_index;
1542 try std.testing.expectError(
1543 error.OutOfMemory,
1544 processor.resizeBilinear(dst[0..], src[0..], 3, 2, 2, 2),
1545 );
1546 try std.testing.expect(failing.has_induced_failure);
1547 try std.testing.expectEqual(before, processor.storageStatus());
1548 try std.testing.expectEqual(dst_id, processor.buffers.dst.?.handle.id);
1549 failing.fail_index = std.math.maxInt(usize);
1550 try processor.resizeBilinear(dst[0..4], src[0..], 2, 2, 2, 2);
1551 }
1552
1553 test "paint image Processor cached admitted launch makes no allocator calls" {
1554 comptime {
1555 @stardustClaim(
1556 @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_steady"),
1557 null,
1558 null,
1559 null,
1560 null,
1561 null,
1562 null,
1563 );
1564 }
1565
1566 var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{});
1567 var state = gpu.recording.BackendState{
1568 .allocator = failing.allocator(),
1569 .kind = .vulkan,
1570 .format = .vulkan_spirv,
1571 };
1572 var processor = try Processor.init(failing.allocator(), state.handle(), .{
1573 .artifact_format = .vulkan_spirv,
1574 .initial_storage = .{ .dst_pixels = 4, .src_pixels = 4 },
1575 });
1576 defer processor.deinit();
1577 var src = @as([4]u32, @splat(0));
1578 var dst = @as([4]u32, @splat(0));
1579 try processor.resizeBilinear(dst[0..], src[0..], 2, 2, 2, 2);
1580 failing.fail_index = failing.alloc_index;
1581 failing.resize_fail_index = failing.resize_index;
1582 try processor.resizeBilinear(dst[0..], src[0..], 2, 2, 2, 2);
1583 try std.testing.expect(!failing.has_induced_failure);
1584 }
1585
1586 test "paint image Processor assembles maximum launch scalars in fixed local storage" {
1587 comptime {
1588 @stardustClaim(
1589 @import("alloc_phase").capacity.witness(ImageHostStorage, "gui_paint_image_scalars"),
1590 null,
1591 null,
1592 null,
1593 null,
1594 null,
1595 null,
1596 );
1597 }
1598
1599 const runtime_arguments = [_]choir_abi.ScalarArgument{
1600 .{ .u32 = 1 },
1601 .{ .u32 = 2 },
1602 .{ .u32 = 3 },
1603 .{ .u32 = 4 },
1604 .{ .u32 = 5 },
1605 .{ .u32 = 6 },
1606 .{ .u32 = 7 },
1607 };
1608 const static_arguments = @as([host_loop_launch_shape_arg_count]choir_abi.ScalarArgument, @splat(.{ .u32 = 0 }));
1609 const entry = .{ .static_arguments = static_arguments[0..] };
1610 const geometry = choir_abi.LaunchGeometry{
1611 .grid = .{ 2, 1, 1 },
1612 .threadgroup = .{ 4, 1, 1 },
1613 };
1614 const scalars = try imageLaunchScalars(entry, .cpu_object, geometry, runtime_arguments[0..]);
1615 try std.testing.expectEqual(@as(usize, scalar_argument_count_max), scalars.count);
1616 try std.testing.expectEqualSlices(
1617 choir_abi.ScalarArgument,
1618 runtime_arguments[0..],
1619 scalars.storage[0..runtime_arguments.len],
1620 );
1621 try std.testing.expectEqual(@as(u32, 8), scalars.storage[runtime_arguments.len].u32);
1622 }
1623
1624 test "paint image processor records image-family shadow blur passes" {
1625 const allocator = std.testing.allocator;
1626 var state = gpu.recording.BackendState{
1627 .allocator = allocator,
1628 .kind = .vulkan,
1629 .format = .vulkan_spirv,
1630 };
1631 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
1632 defer processor.deinit();
1633 const paint = command.Command{
1634 .kind = .shadow,
1635 .rect = .{ .x = 2, .y = 3, .width = 5, .height = 4 },
1636 .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1637 .color = .{ .r = 20, .g = 30, .b = 40, .a = 200 },
1638 .radius = 1,
1639 .width = 2,
1640 .order = 9,
1641 };
1642
1643 var shadow = try processor.shadowAlloc(paint, .{ .image_index = 3 });
1644 defer shadow.deinit(allocator);
1645
1646 try std.testing.expectEqual(command.Kind.image, shadow.command.kind);
1647 try std.testing.expectEqual(@as(u32, 3), shadow.command.image_index);
1648 try std.testing.expectEqual(@as(u32, 9), shadow.command.order);
1649 try std.testing.expectEqual(@as(u32, 9), shadow.image.width);
1650 try std.testing.expectEqual(@as(u32, 8), shadow.image.height);
1651 try std.testing.expectEqual(@as(usize, 2), state.launch_count);
1652 try std.testing.expectEqual(@as(usize, 2), state.load_count);
1653 try std.testing.expectEqual(@as(usize, 1), state.sync_count);
1654 try std.testing.expectEqual(@as(usize, 4), state.buffer_allocate_count);
1655 try std.testing.expectEqual(@as(usize, 2), state.write_count);
1656 try std.testing.expectEqual(@as(usize, 1), state.read_count);
1657 try std.testing.expectEqual(@as(usize, 3), state.last_launch_buffer_count);
1658 try std.testing.expectEqual(gpu.BufferAccess.read_write, state.last_buffer_access[0]);
1659 try std.testing.expectEqual(gpu.BufferAccess.read_only, state.last_buffer_access[1]);
1660 try std.testing.expectEqual(gpu.BufferAccess.read_only, state.last_buffer_access[2]);
1661 try std.testing.expectEqual(@as(usize, 2), state.last_launch_scalar_count);
1662 try std.testing.expectEqual(shadow.image.width, state.last_launch_scalar_u32_values[0]);
1663 try std.testing.expectEqual(shadow.image.height, state.last_launch_scalar_u32_values[1]);
1664 }
1665
1666 test "paint image processor reuses image-family shadow alpha" {
1667 const allocator = std.testing.allocator;
1668 var state = gpu.recording.BackendState{
1669 .allocator = allocator,
1670 .kind = .vulkan,
1671 .format = .vulkan_spirv,
1672 };
1673 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
1674 defer processor.deinit();
1675 const paint = command.Command{
1676 .kind = .shadow,
1677 .rect = .{ .x = 2, .y = 3, .width = 5, .height = 4 },
1678 .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1679 .color = .{ .r = 20, .g = 30, .b = 40, .a = 200 },
1680 .radius = 1,
1681 .width = 2,
1682 .order = 9,
1683 };
1684
1685 var first = try processor.shadowAlloc(paint, .{ .image_index = 3 });
1686 defer first.deinit(allocator);
1687 try std.testing.expectEqual(@as(usize, 2), state.load_count);
1688 try std.testing.expectEqual(@as(usize, 2), state.launch_count);
1689 try std.testing.expectEqual(@as(usize, 1), state.sync_count);
1690 try std.testing.expectEqual(@as(usize, 4), state.buffer_allocate_count);
1691 try std.testing.expectEqual(@as(usize, 2), state.write_count);
1692 try std.testing.expectEqual(@as(usize, 1), state.read_count);
1693
1694 var second = try processor.shadowAlloc(paint, .{ .image_index = 4 });
1695 defer second.deinit(allocator);
1696 try std.testing.expectEqual(@as(usize, 2), state.load_count);
1697 try std.testing.expectEqual(@as(usize, 2), state.launch_count);
1698 try std.testing.expectEqual(@as(usize, 1), state.sync_count);
1699 try std.testing.expectEqual(@as(usize, 4), state.buffer_allocate_count);
1700 try std.testing.expectEqual(@as(usize, 2), state.write_count);
1701 try std.testing.expectEqual(@as(usize, 1), state.read_count);
1702
1703 var changed_rgb = paint;
1704 changed_rgb.color.r = 21;
1705 var third = try processor.shadowAlloc(changed_rgb, .{ .image_index = 5 });
1706 defer third.deinit(allocator);
1707 try std.testing.expectEqual(@as(u32, 5), third.command.image_index);
1708 try std.testing.expectEqual(@as(usize, 2), state.load_count);
1709 try std.testing.expectEqual(@as(usize, 2), state.launch_count);
1710 try std.testing.expectEqual(@as(usize, 1), state.sync_count);
1711 try std.testing.expectEqual(@as(usize, 4), state.buffer_allocate_count);
1712 try std.testing.expectEqual(@as(usize, 2), state.write_count);
1713 try std.testing.expectEqual(@as(usize, 1), state.read_count);
1714
1715 var changed_alpha = paint;
1716 changed_alpha.color.a = 201;
1717 var fourth = try processor.shadowAlloc(changed_alpha, .{ .image_index = 6 });
1718 defer fourth.deinit(allocator);
1719 try std.testing.expectEqual(@as(usize, 2), state.load_count);
1720 try std.testing.expectEqual(@as(usize, 4), state.launch_count);
1721 try std.testing.expectEqual(@as(usize, 2), state.sync_count);
1722 try std.testing.expectEqual(@as(usize, 4), state.buffer_allocate_count);
1723 try std.testing.expectEqual(@as(usize, 3), state.write_count);
1724 try std.testing.expectEqual(@as(usize, 2), state.read_count);
1725
1726 var changed_width = paint;
1727 changed_width.width = 3;
1728 var fifth = try processor.shadowAlloc(changed_width, .{ .image_index = 7 });
1729 defer fifth.deinit(allocator);
1730 try std.testing.expectEqual(@as(usize, 4), state.load_count);
1731 try std.testing.expectEqual(@as(usize, 6), state.launch_count);
1732 try std.testing.expectEqual(@as(usize, 3), state.sync_count);
1733 try std.testing.expectEqual(@as(usize, 8), state.buffer_allocate_count);
1734 try std.testing.expectEqual(@as(usize, 5), state.write_count);
1735 try std.testing.expectEqual(@as(usize, 3), state.read_count);
1736 }
1737
1738 test "paint image processor shadow image matches reference on cpu object" {
1739 const allocator = std.testing.allocator;
1740 var state = gpu.cpu.State.init(allocator);
1741 defer state.deinit();
1742 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .cpu_object });
1743 defer processor.deinit();
1744 const paint = command.Command{
1745 .kind = .shadow,
1746 .rect = .{ .x = 2, .y = 3, .width = 5, .height = 4 },
1747 .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1748 .color = .{ .r = 20, .g = 30, .b = 40, .a = 200 },
1749 .radius = 1,
1750 .width = 2,
1751 .order = 7,
1752 };
1753
1754 var expected = try referenceShadowAlloc(allocator, paint, .{ .image_index = 4 });
1755 defer expected.deinit(allocator);
1756 var actual = try processor.shadowAlloc(paint, .{ .image_index = 4 });
1757 defer actual.deinit(allocator);
1758
1759 try std.testing.expectEqual(expected.command.kind, actual.command.kind);
1760 try std.testing.expectEqual(expected.command.image_index, actual.command.image_index);
1761 try std.testing.expectEqual(expected.command.order, actual.command.order);
1762 try std.testing.expectEqual(expected.image.width, actual.image.width);
1763 try std.testing.expectEqual(expected.image.height, actual.image.height);
1764 try std.testing.expectEqualSlices(u32, expected.pixels, actual.pixels);
1765 const center = actual.pixels[@as(usize, actual.image.width) * 3 + 4];
1766 const center_color = cpu.pixel.unpackRgba(center);
1767 try std.testing.expectEqual(@as(u8, 20), center_color.r);
1768 try std.testing.expectEqual(@as(u8, 30), center_color.g);
1769 try std.testing.expectEqual(@as(u8, 40), center_color.b);
1770 try std.testing.expect(center_color.a > 0);
1771
1772 var changed_rgb = paint;
1773 changed_rgb.color.r = 90;
1774 var expected_changed = try referenceShadowAlloc(allocator, changed_rgb, .{ .image_index = 5 });
1775 defer expected_changed.deinit(allocator);
1776 var actual_changed = try processor.shadowAlloc(changed_rgb, .{ .image_index = 5 });
1777 defer actual_changed.deinit(allocator);
1778
1779 try std.testing.expectEqualSlices(u32, expected_changed.pixels, actual_changed.pixels);
1780 const changed_center = actual_changed.pixels[@as(usize, actual_changed.image.width) * 3 + 4];
1781 const changed_color = cpu.pixel.unpackRgba(changed_center);
1782 try std.testing.expectEqual(@as(u8, 90), changed_color.r);
1783 try std.testing.expectEqual(@as(u8, 30), changed_color.g);
1784 try std.testing.expectEqual(center_color.a, changed_color.a);
1785 }
1786
1787 test "paint image processor expands shadow commands into appended images" {
1788 const allocator = std.testing.allocator;
1789 var state = gpu.cpu.State.init(allocator);
1790 defer state.deinit();
1791 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .cpu_object });
1792 defer processor.deinit();
1793
1794 const base_pixels = [_]u32{
1795 cpu.pixel.packRgba(.{ .r = 255, .a = 255 }),
1796 cpu.pixel.packRgba(.{ .g = 255, .a = 255 }),
1797 cpu.pixel.packRgba(.{ .b = 255, .a = 255 }),
1798 cpu.pixel.packRgba(.{ .r = 255, .g = 255, .a = 255 }),
1799 };
1800 const images = command.ImageSet{ .images = &.{.{
1801 .width = 2,
1802 .height = 2,
1803 .pixels = base_pixels[0..],
1804 }} };
1805 const commands = [_]command.Command{
1806 .{
1807 .kind = .fill,
1808 .rect = .{ .x = 0, .y = 0, .width = 3, .height = 3 },
1809 .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1810 .color = .{ .r = 200, .a = 255 },
1811 .order = 0,
1812 },
1813 .{
1814 .kind = .shadow,
1815 .rect = .{ .x = 3, .y = 4, .width = 5, .height = 4 },
1816 .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1817 .color = .{ .r = 20, .g = 30, .b = 40, .a = 200 },
1818 .radius = 1,
1819 .width = 2,
1820 .order = 2,
1821 },
1822 .{
1823 .kind = .image,
1824 .rect = .{ .x = 8, .y = 1, .width = 2, .height = 2 },
1825 .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1826 .color = .{ .r = 255, .g = 255, .b = 255, .a = 255 },
1827 .image_index = 0,
1828 .order = 1,
1829 },
1830 };
1831
1832 var expansion = try processor.expandShadowsAlloc(commands[0..], images, .{});
1833 defer expansion.deinit();
1834 const expanded_images = expansion.imageSet();
1835
1836 try std.testing.expectEqual(commands.len, expansion.commands.len);
1837 try std.testing.expectEqual(@as(usize, 2), expanded_images.images.len);
1838 try std.testing.expectEqual(command.Kind.fill, expansion.commands[0].kind);
1839 try std.testing.expectEqual(command.Kind.image, expansion.commands[1].kind);
1840 try std.testing.expectEqual(command.Kind.image, expansion.commands[2].kind);
1841 try std.testing.expectEqual(@as(u32, 1), expansion.commands[1].image_index);
1842 try std.testing.expectEqual(@as(u32, 0), expansion.commands[2].image_index);
1843 try std.testing.expectEqual(@as(u32, 2), expansion.commands[1].order);
1844 try std.testing.expectEqualSlices(u32, base_pixels[0..], expanded_images.images[0].pixels);
1845
1846 var expected = try referenceShadowAlloc(allocator, commands[1], .{ .image_index = 1 });
1847 defer expected.deinit(allocator);
1848 try std.testing.expectEqual(expected.command.kind, expansion.commands[1].kind);
1849 try std.testing.expectEqual(expected.command.image_index, expansion.commands[1].image_index);
1850 try std.testing.expectEqual(expected.image.width, expanded_images.images[1].width);
1851 try std.testing.expectEqual(expected.image.height, expanded_images.images[1].height);
1852 try std.testing.expectEqualSlices(u32, expected.pixels, expanded_images.images[1].pixels);
1853
1854 var target_pixels = @as([(16 * 16)]u32, @splat(0));
1855 try cpu.renderCommandsPackedWithImages(expansion.commands, .{
1856 .width = 16,
1857 .height = 16,
1858 .pixels = target_pixels[0..],
1859 }, .{ .a = 0 }, expanded_images);
1860 try std.testing.expect(target_pixels[4 * 16 + 4] != 0);
1861 }
1862
1863 test "paint image processor records shadow command expansion launches" {
1864 const allocator = std.testing.allocator;
1865 var state = gpu.recording.BackendState{
1866 .allocator = allocator,
1867 .kind = .vulkan,
1868 .format = .vulkan_spirv,
1869 };
1870 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
1871 defer processor.deinit();
1872 const commands = [_]command.Command{.{
1873 .kind = .shadow,
1874 .rect = .{ .x = 2, .y = 3, .width = 5, .height = 4 },
1875 .clip = .{ .x = 0, .y = 0, .width = 16, .height = 16 },
1876 .color = .{ .r = 20, .g = 30, .b = 40, .a = 200 },
1877 .radius = 1,
1878 .width = 2,
1879 .order = 9,
1880 }};
1881
1882 var expansion = try processor.expandShadowsAlloc(commands[0..], .{}, .{});
1883 defer expansion.deinit();
1884
1885 try std.testing.expectEqual(@as(usize, 1), expansion.commands.len);
1886 try std.testing.expectEqual(@as(usize, 1), expansion.imageSet().images.len);
1887 try std.testing.expectEqual(command.Kind.image, expansion.commands[0].kind);
1888 try std.testing.expectEqual(@as(u32, 0), expansion.commands[0].image_index);
1889 try std.testing.expectEqual(@as(usize, 2), state.launch_count);
1890 try std.testing.expectEqual(@as(usize, 2), state.load_count);
1891 try std.testing.expectEqual(@as(usize, 1), state.sync_count);
1892 }
1893
1894 test "paint image processor expansion preserves command lists without shadows" {
1895 const allocator = std.testing.allocator;
1896 var state = gpu.recording.BackendState{
1897 .allocator = allocator,
1898 .kind = .vulkan,
1899 .format = .vulkan_spirv,
1900 };
1901 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
1902 defer processor.deinit();
1903 const pixels = [_]u32{cpu.pixel.packRgba(.{ .r = 255, .a = 255 })};
1904 const images = command.ImageSet{ .images = &.{.{ .width = 1, .height = 1, .pixels = pixels[0..] }} };
1905 const commands = [_]command.Command{
1906 .{
1907 .kind = .fill,
1908 .rect = .{ .x = 0, .y = 0, .width = 3, .height = 3 },
1909 .clip = .{ .x = 0, .y = 0, .width = 8, .height = 8 },
1910 .color = .{ .r = 200, .a = 255 },
1911 },
1912 .{
1913 .kind = .image,
1914 .rect = .{ .x = 3, .y = 3, .width = 1, .height = 1 },
1915 .clip = .{ .x = 0, .y = 0, .width = 8, .height = 8 },
1916 .color = .{ .r = 255, .g = 255, .b = 255, .a = 255 },
1917 .image_index = 0,
1918 },
1919 };
1920
1921 var expansion = try processor.expandShadowsAlloc(commands[0..], images, .{});
1922 defer expansion.deinit();
1923
1924 try std.testing.expectEqualSlices(command.Command, commands[0..], expansion.commands);
1925 try std.testing.expectEqual(@as(usize, 1), expansion.imageSet().images.len);
1926 try std.testing.expectEqualSlices(u32, pixels[0..], expansion.imageSet().images[0].pixels);
1927 try std.testing.expectEqual(@as(usize, 0), state.launch_count);
1928 }
1929
1930 test "paint image processor records blur catalog launch" {
1931 const allocator = std.testing.allocator;
1932 var state = gpu.recording.BackendState{
1933 .allocator = allocator,
1934 .kind = .vulkan,
1935 .format = .vulkan_spirv,
1936 };
1937 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
1938 defer processor.deinit();
1939 const width: u32 = 33;
1940 const height: u32 = 17;
1941 const radius: u32 = 1;
1942 const pixels = try pixelCount(width, height);
1943 const taps = try blurTapCount(radius);
1944
1945 const dst = try allocateDeviceBuffer(state.handle(), u32, .u32, pixels);
1946 defer state.handle().destroyObject(dst.id);
1947 const src = try allocateDeviceBuffer(state.handle(), u32, .u32, pixels);
1948 defer state.handle().destroyObject(src.id);
1949 const weights = try allocateDeviceBuffer(state.handle(), f32, .f32, taps);
1950 defer state.handle().destroyObject(weights.id);
1951
1952 try processor.launchBlurPass(dst, src, weights, width, height, radius, .vertical);
1953
1954 const threads = image_library.imageThreadsForExtents(width, height);
1955 try std.testing.expectEqual(@as(usize, 1), state.load_count);
1956 try std.testing.expectEqual(@as(usize, 1), state.launch_count);
1957 try std.testing.expectEqual(@as(usize, 1), state.sync_count);
1958 try std.testing.expectEqual(@as(usize, 3), state.last_launch_buffer_count);
1959 try std.testing.expectEqual(@as(usize, 2), state.last_launch_scalar_count);
1960 try std.testing.expectEqual(width, state.last_launch_scalar_u32_values[0]);
1961 try std.testing.expectEqual(height, state.last_launch_scalar_u32_values[1]);
1962 try std.testing.expectEqual((width + threads.x - 1) / threads.x, state.last_launch_grid[0]);
1963 try std.testing.expectEqual((height + threads.y - 1) / threads.y, state.last_launch_grid[1]);
1964 try std.testing.expectEqual(@as(u32, 1), state.last_launch_grid[2]);
1965 try std.testing.expectEqual(threads.x, state.last_launch_threadgroup[0]);
1966 try std.testing.expectEqual(threads.y, state.last_launch_threadgroup[1]);
1967 try std.testing.expectEqual(gpu.BufferAccess.read_write, state.last_buffer_access[0]);
1968 try std.testing.expectEqual(gpu.BufferAccess.read_only, state.last_buffer_access[1]);
1969 try std.testing.expectEqual(gpu.BufferAccess.read_only, state.last_buffer_access[2]);
1970 }
1971
1972 test "paint image processor blur pass matches Accy image reference on cpu object" {
1973 const allocator = std.testing.allocator;
1974 var state = gpu.cpu.State.init(allocator);
1975 defer state.deinit();
1976
1977 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .cpu_object });
1978 defer processor.deinit();
1979 const width: u32 = 5;
1980 const height: u32 = 4;
1981 const pixels = try pixelCount(width, height);
1982 const radius: u32 = 1;
1983 const sigma: f32 = 1.0;
1984
1985 const src = try allocator.alloc(u32, pixels);
1986 defer allocator.free(src);
1987 for (src, 0..) |*pixel, index| pixel.* = testPixel(index + 3);
1988
1989 const actual = try allocator.alloc(u32, pixels);
1990 defer allocator.free(actual);
1991 @memset(actual, 0);
1992
1993 const expected = try allocator.alloc(u32, pixels);
1994 defer allocator.free(expected);
1995 const weights = try image_library.gaussianWeightsAlloc(allocator, radius, sigma);
1996 defer allocator.free(weights);
1997 image_library.referenceBlurPass(expected, src, weights, width, height, .horizontal);
1998
1999 try processor.blurPass(actual, src, width, height, radius, sigma, .horizontal);
2000 try std.testing.expectEqualSlices(u32, expected, actual);
2001 }
2002
2003 test "paint image processor reuses blur pass buffers" {
2004 const allocator = std.testing.allocator;
2005 var state = gpu.recording.BackendState{
2006 .allocator = allocator,
2007 .kind = .vulkan,
2008 .format = .vulkan_spirv,
2009 };
2010 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
2011 defer processor.deinit();
2012 const width: u32 = 5;
2013 const height: u32 = 4;
2014 const pixels: usize = width * height;
2015 var src = @as([pixels]u32, @splat(0));
2016 for (src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 101);
2017 var dst = @as([pixels]u32, @splat(0));
2018
2019 try processor.blurPass(dst[0..], src[0..], width, height, 1, 1.0, .horizontal);
2020 try std.testing.expectEqual(@as(usize, 3), state.buffer_allocate_count);
2021 try std.testing.expectEqual(@as(usize, 1), state.launch_count);
2022 try std.testing.expectEqual(@as(usize, 2), state.write_count);
2023 try std.testing.expectEqual(@as(usize, 1), state.read_count);
2024
2025 try processor.blurPass(dst[0..], src[0..], width, height, 1, 1.0, .horizontal);
2026 try std.testing.expectEqual(@as(usize, 3), state.buffer_allocate_count);
2027 try std.testing.expectEqual(@as(usize, 2), state.launch_count);
2028 try std.testing.expectEqual(@as(usize, 3), state.write_count);
2029 try std.testing.expectEqual(@as(usize, 2), state.read_count);
2030
2031 const larger_width: u32 = 6;
2032 const larger_pixels: usize = larger_width * height;
2033 var larger_src = @as([larger_pixels]u32, @splat(0));
2034 for (larger_src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 131);
2035 var larger_dst = @as([larger_pixels]u32, @splat(0));
2036
2037 try processor.blurPass(larger_dst[0..], larger_src[0..], larger_width, height, 2, 1.0, .horizontal);
2038 try std.testing.expectEqual(@as(usize, 6), state.buffer_allocate_count);
2039 try std.testing.expectEqual(@as(usize, 3), state.launch_count);
2040 try std.testing.expectEqual(@as(usize, 5), state.write_count);
2041 try std.testing.expectEqual(@as(usize, 3), state.read_count);
2042 }
2043
2044 test "paint image processor resize bilinear matches Accy image reference on cpu object" {
2045 const allocator = std.testing.allocator;
2046 var state = gpu.cpu.State.init(allocator);
2047 defer state.deinit();
2048
2049 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .cpu_object });
2050 defer processor.deinit();
2051 const src_width: u32 = 3;
2052 const src_height: u32 = 2;
2053 const dst_width: u32 = 5;
2054 const dst_height: u32 = 4;
2055 const src_pixels = try pixelCount(src_width, src_height);
2056 const dst_pixels = try pixelCount(dst_width, dst_height);
2057
2058 const src = try allocator.alloc(u32, src_pixels);
2059 defer allocator.free(src);
2060 for (src, 0..) |*pixel, index| pixel.* = testPixel(index + 11);
2061
2062 const actual = try allocator.alloc(u32, dst_pixels);
2063 defer allocator.free(actual);
2064 @memset(actual, 0);
2065
2066 const expected = try allocator.alloc(u32, dst_pixels);
2067 defer allocator.free(expected);
2068 image_library.referenceResizeBilinear(expected, src, dst_width, dst_height, src_width, src_height);
2069
2070 try processor.resizeBilinear(actual, src, dst_width, dst_height, src_width, src_height);
2071 try std.testing.expectEqualSlices(u32, expected, actual);
2072 }
2073
2074 test "paint image processor records owned resize image launch" {
2075 const allocator = std.testing.allocator;
2076 var state = gpu.recording.BackendState{
2077 .allocator = allocator,
2078 .kind = .vulkan,
2079 .format = .vulkan_spirv,
2080 };
2081 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
2082 defer processor.deinit();
2083 const src_width: u32 = 3;
2084 const src_height: u32 = 2;
2085 const dst_width: u32 = 5;
2086 const dst_height: u32 = 4;
2087 var src = @as([(src_width * src_height)]u32, @splat(0));
2088 for (src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 31);
2089
2090 var resized = try processor.resizeAlloc(.{
2091 .width = src_width,
2092 .height = src_height,
2093 .pixels = src[0..],
2094 }, dst_width, dst_height);
2095 defer resized.deinit(allocator);
2096
2097 try std.testing.expectEqual(dst_width, resized.image.width);
2098 try std.testing.expectEqual(dst_height, resized.image.height);
2099 try std.testing.expectEqual(@as(usize, dst_width * dst_height), resized.image.pixels.len);
2100 try std.testing.expectEqual(resized.pixels.ptr, resized.image.pixels.ptr);
2101 try std.testing.expectEqual(@as(usize, 1), state.launch_count);
2102 try std.testing.expectEqual(@as(usize, 1), state.load_count);
2103 try std.testing.expectEqual(@as(usize, 1), state.sync_count);
2104 try std.testing.expectEqual(@as(usize, 1), state.write_count);
2105 try std.testing.expectEqual(@as(usize, 1), state.read_count);
2106 try std.testing.expectEqual(@as(usize, 2), state.last_launch_buffer_count);
2107 try std.testing.expectEqual(@as(usize, 7), state.last_launch_scalar_count);
2108 try std.testing.expectEqual(dst_width, state.last_launch_scalar_u32_values[0]);
2109 try std.testing.expectEqual(dst_height, state.last_launch_scalar_u32_values[1]);
2110 try std.testing.expectEqual(src_width, state.last_launch_scalar_u32_values[2]);
2111 try std.testing.expectApproxEqAbs(image_library.resizeScale(src_width, dst_width), state.last_launch_scalar_f32_values[3], 0.00001);
2112 try std.testing.expectApproxEqAbs(image_library.resizeScale(src_height, dst_height), state.last_launch_scalar_f32_values[4], 0.00001);
2113 }
2114
2115 test "paint image processor reuses image-family resize kernels" {
2116 const allocator = std.testing.allocator;
2117 var state = gpu.recording.BackendState{
2118 .allocator = allocator,
2119 .kind = .vulkan,
2120 .format = .vulkan_spirv,
2121 };
2122 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
2123 defer processor.deinit();
2124 const src_width: u32 = 3;
2125 const src_height: u32 = 2;
2126 const dst_width: u32 = 5;
2127 const dst_height: u32 = 4;
2128 var src = @as([(src_width * src_height)]u32, @splat(0));
2129 for (src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 41);
2130
2131 var first = try processor.resizeAlloc(.{
2132 .width = src_width,
2133 .height = src_height,
2134 .pixels = src[0..],
2135 }, dst_width, dst_height);
2136 defer first.deinit(allocator);
2137 try std.testing.expectEqual(@as(usize, 1), state.load_count);
2138 try std.testing.expectEqual(@as(usize, 1), state.launch_count);
2139 try std.testing.expectEqual(@as(usize, 2), state.buffer_allocate_count);
2140
2141 var second = try processor.resizeAlloc(.{
2142 .width = src_width,
2143 .height = src_height,
2144 .pixels = src[0..],
2145 }, dst_width, dst_height);
2146 defer second.deinit(allocator);
2147 try std.testing.expectEqual(@as(usize, 1), state.load_count);
2148 try std.testing.expectEqual(@as(usize, 2), state.launch_count);
2149 try std.testing.expectEqual(@as(usize, 2), state.buffer_allocate_count);
2150
2151 var third = try processor.resizeAlloc(.{
2152 .width = src_width,
2153 .height = src_height,
2154 .pixels = src[0..],
2155 }, dst_width + 1, dst_height);
2156 defer third.deinit(allocator);
2157 try std.testing.expectEqual(@as(usize, 2), state.load_count);
2158 try std.testing.expectEqual(@as(usize, 3), state.launch_count);
2159 try std.testing.expectEqual(@as(usize, 4), state.buffer_allocate_count);
2160 }
2161
2162 test "paint image processor reads retained larger resize buffers into active result" {
2163 const allocator = std.testing.allocator;
2164 var state = gpu.recording.BackendState{
2165 .allocator = allocator,
2166 .kind = .vulkan,
2167 .format = .vulkan_spirv,
2168 };
2169 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
2170 defer processor.deinit();
2171 const src_width: u32 = 3;
2172 const src_height: u32 = 2;
2173 var src = @as([(src_width * src_height)]u32, @splat(0));
2174 for (src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 151);
2175
2176 var larger = try processor.resizeAlloc(.{
2177 .width = src_width,
2178 .height = src_height,
2179 .pixels = src[0..],
2180 }, 5, 4);
2181 defer larger.deinit(allocator);
2182 try std.testing.expectEqual(@as(usize, 20), larger.pixels.len);
2183 try std.testing.expectEqual(@as(usize, 80), state.last_read_byte_count);
2184
2185 var smaller = try processor.resizeAlloc(.{
2186 .width = src_width,
2187 .height = src_height,
2188 .pixels = src[0..],
2189 }, 2, 2);
2190 defer smaller.deinit(allocator);
2191 try std.testing.expectEqual(@as(usize, 4), smaller.pixels.len);
2192 try std.testing.expectEqual(@as(usize, 2), state.buffer_allocate_count);
2193 try std.testing.expectEqual(@as(usize, 2), state.read_count);
2194 try std.testing.expectEqual(@as(usize, 80), state.last_read_byte_count);
2195 }
2196
2197 test "paint image processor owned resize image matches Accy image reference on cpu object" {
2198 const allocator = std.testing.allocator;
2199 var state = gpu.cpu.State.init(allocator);
2200 defer state.deinit();
2201
2202 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .cpu_object });
2203 defer processor.deinit();
2204 const src_width: u32 = 4;
2205 const src_height: u32 = 3;
2206 const dst_width: u32 = 2;
2207 const dst_height: u32 = 5;
2208 const src_pixels = try pixelCount(src_width, src_height);
2209 const dst_pixels = try pixelCount(dst_width, dst_height);
2210 const src = try allocator.alloc(u32, src_pixels);
2211 defer allocator.free(src);
2212 for (src, 0..) |*pixel, index| pixel.* = testPixel(index + 47);
2213 const expected = try allocator.alloc(u32, dst_pixels);
2214 defer allocator.free(expected);
2215 image_library.referenceResizeBilinear(expected, src, dst_width, dst_height, src_width, src_height);
2216
2217 var resized = try processor.resizeAlloc(.{
2218 .width = src_width,
2219 .height = src_height,
2220 .pixels = src,
2221 }, dst_width, dst_height);
2222 defer resized.deinit(allocator);
2223
2224 try std.testing.expectEqual(dst_width, resized.image.width);
2225 try std.testing.expectEqual(dst_height, resized.image.height);
2226 try std.testing.expectEqualSlices(u32, expected, resized.pixels);
2227 try std.testing.expectEqualSlices(u32, expected, resized.image.pixels);
2228 }
2229
2230 test "paint image thumbnail extent fits max box without upscaling" {
2231 try std.testing.expectEqual(ImageExtent{ .width = 100, .height = 50 }, try thumbnailExtent(400, 200, 100, 100));
2232 try std.testing.expectEqual(ImageExtent{ .width = 50, .height = 100 }, try thumbnailExtent(200, 400, 100, 100));
2233 try std.testing.expectEqual(ImageExtent{ .width = 100, .height = 50 }, try thumbnailExtent(100, 50, 500, 500));
2234 try std.testing.expectEqual(ImageExtent{ .width = 4, .height = 1 }, try thumbnailExtent(7, 3, 4, 4));
2235 try std.testing.expectError(error.InvalidImage, thumbnailExtent(1, 1, 0, 4));
2236 }
2237
2238 test "paint image processor records thumbnail resize launch" {
2239 const allocator = std.testing.allocator;
2240 var state = gpu.recording.BackendState{
2241 .allocator = allocator,
2242 .kind = .vulkan,
2243 .format = .vulkan_spirv,
2244 };
2245 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
2246 defer processor.deinit();
2247 const src_width: u32 = 4;
2248 const src_height: u32 = 2;
2249 const dst_width: u32 = 2;
2250 const dst_height: u32 = 1;
2251 var src = @as([(src_width * src_height)]u32, @splat(0));
2252 for (src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 71);
2253
2254 var thumbnail = try processor.thumbnailAlloc(.{
2255 .width = src_width,
2256 .height = src_height,
2257 .pixels = src[0..],
2258 }, .{
2259 .max_width = 2,
2260 .max_height = 2,
2261 });
2262 defer thumbnail.deinit(allocator);
2263
2264 try std.testing.expectEqual(dst_width, thumbnail.image.width);
2265 try std.testing.expectEqual(dst_height, thumbnail.image.height);
2266 try std.testing.expectEqual(@as(usize, dst_width * dst_height), thumbnail.pixels.len);
2267 try std.testing.expectEqual(@as(usize, 1), state.launch_count);
2268 try std.testing.expectEqual(@as(usize, 1), state.load_count);
2269 try std.testing.expectEqual(@as(usize, 1), state.sync_count);
2270 try std.testing.expectEqual(@as(usize, 1), state.write_count);
2271 try std.testing.expectEqual(@as(usize, 1), state.read_count);
2272 try std.testing.expectEqual(@as(usize, 7), state.last_launch_scalar_count);
2273 try std.testing.expectEqual(dst_width, state.last_launch_scalar_u32_values[0]);
2274 try std.testing.expectEqual(dst_height, state.last_launch_scalar_u32_values[1]);
2275 try std.testing.expectEqual(src_width, state.last_launch_scalar_u32_values[2]);
2276 try std.testing.expectApproxEqAbs(image_library.resizeScale(src_width, dst_width), state.last_launch_scalar_f32_values[3], 0.00001);
2277 try std.testing.expectApproxEqAbs(image_library.resizeScale(src_height, dst_height), state.last_launch_scalar_f32_values[4], 0.00001);
2278 }
2279
2280 test "paint image processor thumbnail copies already fitting images without launch" {
2281 const allocator = std.testing.allocator;
2282 var state = gpu.recording.BackendState{
2283 .allocator = allocator,
2284 .kind = .vulkan,
2285 .format = .vulkan_spirv,
2286 };
2287 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .vulkan_spirv });
2288 defer processor.deinit();
2289 const src_width: u32 = 2;
2290 const src_height: u32 = 2;
2291 var src = @as([(src_width * src_height)]u32, @splat(0));
2292 for (src[0..], 0..) |*pixel, index| pixel.* = testPixel(index + 83);
2293
2294 var thumbnail = try processor.thumbnailAlloc(.{
2295 .width = src_width,
2296 .height = src_height,
2297 .pixels = src[0..],
2298 }, .{
2299 .max_width = 8,
2300 .max_height = 8,
2301 });
2302 defer thumbnail.deinit(allocator);
2303
2304 try std.testing.expectEqual(src_width, thumbnail.image.width);
2305 try std.testing.expectEqual(src_height, thumbnail.image.height);
2306 try std.testing.expectEqualSlices(u32, src[0..], thumbnail.pixels);
2307 try std.testing.expectEqualSlices(u32, src[0..], thumbnail.image.pixels);
2308 try std.testing.expect(@intFromPtr(src[0..].ptr) != @intFromPtr(thumbnail.pixels.ptr));
2309 try std.testing.expectEqual(@as(usize, 0), state.launch_count);
2310 try std.testing.expectEqual(@as(usize, 0), state.write_count);
2311 try std.testing.expectEqual(@as(usize, 0), state.read_count);
2312 }
2313
2314 test "paint image processor thumbnail image matches Accy image reference on cpu object" {
2315 const allocator = std.testing.allocator;
2316 var state = gpu.cpu.State.init(allocator);
2317 defer state.deinit();
2318
2319 var processor = try Processor.init(allocator, state.handle(), .{ .artifact_format = .cpu_object });
2320 defer processor.deinit();
2321 const src_width: u32 = 6;
2322 const src_height: u32 = 4;
2323 const dst_width: u32 = 3;
2324 const dst_height: u32 = 2;
2325 const src_pixels = try pixelCount(src_width, src_height);
2326 const dst_pixels = try pixelCount(dst_width, dst_height);
2327 const src = try allocator.alloc(u32, src_pixels);
2328 defer allocator.free(src);
2329 for (src, 0..) |*pixel, index| pixel.* = testPixel(index + 97);
2330 const expected = try allocator.alloc(u32, dst_pixels);
2331 defer allocator.free(expected);
2332 image_library.referenceResizeBilinear(expected, src, dst_width, dst_height, src_width, src_height);
2333
2334 var thumbnail = try processor.thumbnailAlloc(.{
2335 .width = src_width,
2336 .height = src_height,
2337 .pixels = src,
2338 }, .{
2339 .max_width = 3,
2340 .max_height = 3,
2341 });
2342 defer thumbnail.deinit(allocator);
2343
2344 try std.testing.expectEqual(dst_width, thumbnail.image.width);
2345 try std.testing.expectEqual(dst_height, thumbnail.image.height);
2346 try std.testing.expectEqualSlices(u32, expected, thumbnail.pixels);
2347 try std.testing.expectEqualSlices(u32, expected, thumbnail.image.pixels);
2348 }