diff --git a/spec/046_fill_triangle_alpha.zig b/spec/046_fill_triangle_alpha.zig new file mode 100644 index 0000000..24a23a9 --- /dev/null +++ b/spec/046_fill_triangle_alpha.zig @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: 0BSD +// Copyright © 2024 Chris Marchesi + +//! Case: Renders and fills a triangle on a 300x300 surface. +//! +//! This is similar to the 003_fill_triangle.zig, but uses alpha8 as its source +//! versus RGB. +const mem = @import("std").mem; + +const z2d = @import("z2d"); + +pub const filename = "046_fill_triangle_alpha"; + +pub fn render(alloc: mem.Allocator, aa_mode: z2d.options.AntiAliasMode) !z2d.Surface { + const width = 300; + const height = 300; + const sfc = try z2d.Surface.initPixel( + .{ .rgb = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF } }, // White so that srcOver shows up correctly + alloc, + width, + height, + ); + + var context: z2d.Context = .{ + .surface = sfc, + .pattern = .{ + .opaque_pattern = .{ + .pixel = .{ .alpha8 = .{ .a = 255 } }, + }, + }, + .anti_aliasing_mode = aa_mode, + }; + + var path = z2d.Path.init(alloc); + defer path.deinit(); + + const margin = 10; + try path.moveTo(0 + margin, 0 + margin); + try path.lineTo(width - margin - 1, 0 + margin); + try path.lineTo(width / 2 - 1, height - margin - 1); + try path.close(); + + try context.fill(alloc, path); + + return sfc; +} diff --git a/spec/files/046_fill_triangle_alpha_pixelated.png b/spec/files/046_fill_triangle_alpha_pixelated.png new file mode 100644 index 0000000..f27d357 Binary files /dev/null and b/spec/files/046_fill_triangle_alpha_pixelated.png differ diff --git a/spec/files/046_fill_triangle_alpha_smooth.png b/spec/files/046_fill_triangle_alpha_smooth.png new file mode 100644 index 0000000..d074a57 Binary files /dev/null and b/spec/files/046_fill_triangle_alpha_smooth.png differ diff --git a/spec/main.zig b/spec/main.zig index 10103d6..0c12f53 100644 --- a/spec/main.zig +++ b/spec/main.zig @@ -56,6 +56,7 @@ const _042_arc_ellipses = @import("042_arc_ellipses.zig"); const _043_rect_transforms = @import("043_rect_transforms.zig"); const _044_line_transforms = @import("044_line_transforms.zig"); const _045_round_join_transforms = @import("045_round_join_transforms.zig"); +const _046_fill_triangle_alpha = @import("046_fill_triangle_alpha.zig"); ////////////////////////////////////////////////////////////////////////////// @@ -108,6 +109,7 @@ pub fn main() !void { try pathExportRun(alloc, _043_rect_transforms); try pathExportRun(alloc, _044_line_transforms); try pathExportRun(alloc, _045_round_join_transforms); + try pathExportRun(alloc, _046_fill_triangle_alpha); } ////////////////////////////////////////////////////////////////////////////// @@ -292,6 +294,10 @@ test "045_round_join_transforms" { try pathTestRun(testing.allocator, _045_round_join_transforms); } +test "046_fill_triangle_alpha" { + try pathTestRun(testing.allocator, _046_fill_triangle_alpha); +} + ////////////////////////////////////////////////////////////////////////////// fn compositorExportRun(alloc: mem.Allocator, subject: anytype) !void { diff --git a/src/Context.zig b/src/Context.zig index 89e1248..9160181 100644 --- a/src/Context.zig +++ b/src/Context.zig @@ -8,14 +8,23 @@ const Context = @This(); const mem = @import("std").mem; const options = @import("options.zig"); +const paintpkg = @import("internal/Painter.zig"); const Path = @import("Path.zig"); const Pattern = @import("pattern.zig").Pattern; -const Painter = @import("internal/Painter.zig"); const Surface = @import("surface.zig").Surface; const Transformation = @import("Transformation.zig"); const PathError = @import("errors.zig").PathError; +/// The default value to use for the edge cache during rasterization, in number +/// of edges that can be stored per scanline. +/// +/// Note that this cannot be modified in the context. If you require a +/// different value, either due to OOM issues due to a large number of edges, +/// or if you want a smaller buffer, see internal/Painter.zig. Note that this +/// API is currently unstable. +comptime default_edge_cache_size: usize = 1024, + /// The underlying surface. surface: Surface, @@ -91,9 +100,7 @@ transformation: Transformation = Transformation.identity, /// /// This is a no-op if there are no nodes. pub fn fill(self: *Context, alloc: mem.Allocator, path: Path) !void { - if (path.nodes.items.len == 0) return; - if (!path.isClosed()) return PathError.PathNotClosed; - try (Painter{ .context = self }).fill(alloc, path.nodes); + try (paintpkg.Painter(self.default_edge_cache_size){ .context = self }).fill(alloc, path.nodes); } /// Strokes a line for the path(s) in the supplied set. @@ -108,6 +115,5 @@ pub fn fill(self: *Context, alloc: mem.Allocator, path: Path) !void { /// /// This is a no-op if there are no nodes. pub fn stroke(self: *Context, alloc: mem.Allocator, path: Path) !void { - if (path.nodes.items.len == 0) return; - try (Painter{ .context = self }).stroke(alloc, path.nodes); + try (paintpkg.Painter(self.default_edge_cache_size){ .context = self }).stroke(alloc, path.nodes); } diff --git a/src/internal/Painter.zig b/src/internal/Painter.zig index f83f096..715dfc3 100644 --- a/src/internal/Painter.zig +++ b/src/internal/Painter.zig @@ -2,7 +2,6 @@ // Copyright © 2024 Chris Marchesi //! Painter represents the internal code related to painting (fill/stroke/etc). -const Painter = @This(); const std = @import("std"); const debug = @import("std").debug; @@ -13,266 +12,297 @@ const mem = @import("std").mem; const fill_plotter = @import("FillPlotter.zig"); const Context = @import("../Context.zig"); +const Path = @import("../Path.zig"); const PathNode = @import("path_nodes.zig").PathNode; const RGBA = @import("../pixel.zig").RGBA; const Surface = @import("../surface.zig").Surface; const FillRule = @import("../options.zig").FillRule; const StrokePlotter = @import("StrokePlotter.zig"); +const Polygon = @import("Polygon.zig"); const PolygonList = @import("PolygonList.zig"); const Transformation = @import("../Transformation.zig"); const InternalError = @import("../errors.zig").InternalError; +const PathError = @import("../errors.zig").PathError; const supersample_scale = @import("../surface.zig").supersample_scale; -/// The reference to the context that we use for painting operations. -context: *Context, +pub fn Painter(comptime edge_cache_size: usize) type { + return struct { + /// The reference to the context that we use for painting operations. + context: *Context, -/// Runs a fill operation on this current path and any subpaths. -pub fn fill( - self: *const Painter, - alloc: mem.Allocator, - nodes: std.ArrayList(PathNode), -) !void { - // There should be a minimum of two nodes in anything passed here. - // Additionally, the higher-level path API also always adds an explicit - // move_to after close_path nodes, so we assert on this. - // - // NOTE: obviously, to be useful, there would be much more than two nodes, - // but this is just the minimum for us to assert that the path has been - // closed correctly. - if (nodes.items.len < 2) return InternalError.InvalidPathData; - if (nodes.items[nodes.items.len - 2] != .close_path) return InternalError.InvalidPathData; - if (nodes.getLast() != .move_to) return InternalError.InvalidPathData; + /// The value to use for the edge cache during rasterization. This must be a + /// comptime value. + comptime edge_cache_size: usize = edge_cache_size, - const scale: f64 = switch (self.context.anti_aliasing_mode) { - .none => 1, - .default => supersample_scale, - }; + /// Runs a fill operation on this current path and any subpaths. + pub fn fill( + self: *const @This(), + alloc: mem.Allocator, + nodes: std.ArrayList(PathNode), + ) !void { + // TODO: These path safety checks have been moved from the Context + // down to here for now. The Painter API will soon be promoted to + // being public, so this should be fine for now). + if (nodes.items.len == 0) return; + if (!(Path{ .nodes = nodes }).isClosed()) return PathError.PathNotClosed; - var polygons = try fill_plotter.plot( - alloc, - nodes, - scale, - @max(self.context.tolerance, 0.001), - ); - defer polygons.deinit(); + // There should be a minimum of two nodes in anything passed here. + // Additionally, the higher-level path API also always adds an explicit + // move_to after close_path nodes, so we assert on this. + // + // NOTE: obviously, to be useful, there would be much more than two nodes, + // but this is just the minimum for us to assert that the path has been + // closed correctly. + if (nodes.items.len < 2) return InternalError.InvalidPathData; + if (nodes.items[nodes.items.len - 2] != .close_path) return InternalError.InvalidPathData; + if (nodes.getLast() != .move_to) return InternalError.InvalidPathData; - switch (self.context.anti_aliasing_mode) { - .none => { - try self.paintDirect(alloc, polygons, self.context.fill_rule); - }, - .default => { - try self.paintComposite(alloc, polygons, self.context.fill_rule, scale); - }, - } -} + const scale: f64 = switch (self.context.anti_aliasing_mode) { + .none => 1, + .default => supersample_scale, + }; -/// Runs a stroke operation on this path and any sub-paths. The path is -/// transformed to a fillable polygon representing the line, and the line is -/// then filled. -pub fn stroke( - self: *const Painter, - alloc: mem.Allocator, - nodes: std.ArrayList(PathNode), -) !void { - // Should not be called with zero nodes - if (nodes.items.len == 0) return InternalError.InvalidPathData; + var polygons = try fill_plotter.plot( + alloc, + nodes, + scale, + @max(self.context.tolerance, 0.001), + ); + defer polygons.deinit(); - const scale: f64 = switch (self.context.anti_aliasing_mode) { - .none => 1, - .default => supersample_scale, - }; + switch (self.context.anti_aliasing_mode) { + .none => { + try self.paintDirect(polygons, self.context.fill_rule); + }, + .default => { + try self.paintComposite(alloc, polygons, self.context.fill_rule, scale); + }, + } + } - // NOTE: for now, we set a minimum thickness for the following options: - // join_mode, miter_limit, and cap_mode. Any thickness lower than 2 will - // cause these options to revert to the defaults of join_mode = .miter, - // miter_limit = 10.0, cap_mode = .butt. - // - // This is a stop-gap to prevent artifacts with very thin lines (not - // necessarily hairline, but close to being the single-pixel width that are - // used to represent hairlines). As our path builder gets better for - // stroking, I'm expecting that some of these restrictions will be lifted - // and/or moved to specific places where they can be used to address the - // artifacts related to particular edge cases. - // - // Not a stop gap more than likely: minimum line width. This is value is - // sort of arbitrarily chosen as Cairo's minimum 24.8 fixed-point value (so - // 1/256). - const minimum_line_width: f64 = 0.00390625; - var plotter = try StrokePlotter.init( - alloc, - if (self.context.line_width >= minimum_line_width) self.context.line_width else minimum_line_width, - if (self.context.line_width >= 2) self.context.line_join_mode else .miter, - if (self.context.line_width >= 2) self.context.miter_limit else 10.0, - if (self.context.line_width >= 2) self.context.line_cap_mode else .butt, - scale, - @max(self.context.tolerance, 0.001), - self.context.transformation, - ); - defer plotter.deinit(); + /// Runs a stroke operation on this path and any sub-paths. The path is + /// transformed to a fillable polygon representing the line, and the line is + /// then filled. + pub fn stroke( + self: *const @This(), + alloc: mem.Allocator, + nodes: std.ArrayList(PathNode), + ) !void { + // Return if called with zero nodes. + if (nodes.items.len == 0) return; - var polygons = try plotter.plot(alloc, nodes); - defer polygons.deinit(); + const scale: f64 = switch (self.context.anti_aliasing_mode) { + .none => 1, + .default => supersample_scale, + }; - switch (self.context.anti_aliasing_mode) { - .none => { - try self.paintDirect(alloc, polygons, .non_zero); - }, - .default => { - try self.paintComposite(alloc, polygons, .non_zero, scale); - }, - } -} + // NOTE: for now, we set a minimum thickness for the following options: + // join_mode, miter_limit, and cap_mode. Any thickness lower than 2 will + // cause these options to revert to the defaults of join_mode = .miter, + // miter_limit = 10.0, cap_mode = .butt. + // + // This is a stop-gap to prevent artifacts with very thin lines (not + // necessarily hairline, but close to being the single-pixel width that are + // used to represent hairlines). As our path builder gets better for + // stroking, I'm expecting that some of these restrictions will be lifted + // and/or moved to specific places where they can be used to address the + // artifacts related to particular edge cases. + // + // Not a stop gap more than likely: minimum line width. This is value is + // sort of arbitrarily chosen as Cairo's minimum 24.8 fixed-point value (so + // 1/256). + const minimum_line_width: f64 = 0.00390625; + var plotter = try StrokePlotter.init( + alloc, + if (self.context.line_width >= minimum_line_width) self.context.line_width else minimum_line_width, + if (self.context.line_width >= 2) self.context.line_join_mode else .miter, + if (self.context.line_width >= 2) self.context.miter_limit else 10.0, + if (self.context.line_width >= 2) self.context.line_cap_mode else .butt, + scale, + @max(self.context.tolerance, 0.001), + self.context.transformation, + ); + defer plotter.deinit(); -/// Direct paint, writes to surface directly, avoiding compositing. Does not -/// use AA. -fn paintDirect( - self: *const Painter, - alloc: mem.Allocator, - polygons: PolygonList, - fill_rule: FillRule, -) !void { - var arena = heap.ArenaAllocator.init(alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); + var polygons = try plotter.plot(alloc, nodes); + defer polygons.deinit(); - const poly_start_y: i32 = math.clamp( - @as(i32, @intFromFloat(@floor(polygons.start.y))), - 0, - self.context.surface.getHeight() - 1, - ); - const poly_end_y: i32 = math.clamp( - @as(i32, @intFromFloat(@ceil(polygons.end.y))), - 0, - self.context.surface.getHeight() - 1, - ); - var y = poly_start_y; - while (y <= poly_end_y) : (y += 1) { - var edge_list = try polygons.edgesForY(arena_alloc, @floatFromInt(y), fill_rule); - defer edge_list.deinit(); + switch (self.context.anti_aliasing_mode) { + .none => { + try self.paintDirect(polygons, .non_zero); + }, + .default => { + try self.paintComposite(alloc, polygons, .non_zero, scale); + }, + } + } - var start_idx: usize = 0; - while (start_idx + 1 < edge_list.items.len) : (start_idx += 2) { - const start_x = math.clamp( - edge_list.items[start_idx], + /// Direct paint, writes to surface directly, avoiding compositing. Does not + /// use AA. + fn paintDirect( + self: *const @This(), + polygons: PolygonList, + fill_rule: FillRule, + ) !void { + const poly_start_y: i32 = math.clamp( + @as(i32, @intFromFloat(@floor(polygons.start.y))), 0, - self.context.surface.getWidth() - 1, + self.context.surface.getHeight() - 1, ); - // Subtract 1 from the end edge as this is our pixel boundary - // (end_x = 100 actually means we should only fill to x=99). - const end_x = math.clamp( - edge_list.items[start_idx + 1] - 1, + const poly_end_y: i32 = math.clamp( + @as(i32, @intFromFloat(@ceil(polygons.end.y))), 0, - self.context.surface.getWidth() - 1, + self.context.surface.getHeight() - 1, ); + var y = poly_start_y; + while (y <= poly_end_y) : (y += 1) { + // NOTE: FBA here is "freed" by going out of scope. + var edge_buf: [self.edge_cache_size * @sizeOf(Polygon.Edge)]u8 = undefined; + var edge_fba = heap.FixedBufferAllocator.init(&edge_buf); + var edge_list = try polygons.edgesForY(edge_fba.allocator(), @floatFromInt(y), fill_rule); + while (edge_list.next()) |edge_pair| { + const start_x = math.clamp( + edge_pair.start, + 0, + self.context.surface.getWidth() - 1, + ); + // Subtract 1 from the end edge as this is our pixel boundary + // (end_x = 100 actually means we should only fill to x=99). + const end_x = math.clamp( + edge_pair.end - 1, + 0, + self.context.surface.getWidth() - 1, + ); - var x = start_x; - while (x <= end_x) : (x += 1) { - const src = try self.context.pattern.getPixel(x, y); - const dst = try self.context.surface.getPixel(x, y); - try self.context.surface.putPixel(x, y, dst.srcOver(src)); + var x = start_x; + while (x <= end_x) : (x += 1) { + const src = try self.context.pattern.getPixel(x, y); + const dst = try self.context.surface.getPixel(x, y); + try self.context.surface.putPixel(x, y, dst.srcOver(src)); + } + } } } - } -} - -/// Composite paint, for AA and other operations such as gradients (not yet -/// implemented). -fn paintComposite( - self: *const Painter, - alloc: mem.Allocator, - polygons: PolygonList, - fill_rule: FillRule, - scale: f64, -) !void { - var arena = heap.ArenaAllocator.init(alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - - // This math expects integer scaling. - debug.assert(@floor(scale) == scale); - const i_scale: i32 = @intFromFloat(scale); - // This is the area on the original image which our polygons may touch. - // This range is *exclusive* of the right (max) end, hence why we add 1 - // to the maximum coordinates. - const x0: i32 = @intFromFloat(@floor(polygons.start.x / scale)); - const y0: i32 = @intFromFloat(@floor(polygons.start.y / scale)); - const x1: i32 = @intFromFloat(@floor(polygons.end.x / scale) + 1); - const y1: i32 = @intFromFloat(@floor(polygons.end.y / scale) + 1); + /// Composite paint, for AA and other operations such as gradients (not yet + /// implemented). + fn paintComposite( + self: *const @This(), + alloc: mem.Allocator, + polygons: PolygonList, + fill_rule: FillRule, + scale: f64, + ) !void { + // This math expects integer scaling. + debug.assert(@floor(scale) == scale); + const i_scale: i32 = @intFromFloat(scale); - const mask_sfc = sfc_m: { - // We calculate a scaled up version of the - // extents for our supersampled drawing. - const box_x0: i32 = x0 * i_scale; - const box_y0: i32 = y0 * i_scale; - const box_x1: i32 = x1 * i_scale; - const box_y1: i32 = y1 * i_scale; - const mask_width: i32 = box_x1 - box_x0; - const mask_height: i32 = box_y1 - box_y0; - const offset_x: i32 = box_x0; - const offset_y: i32 = box_y0; + // This is the area on the original image which our polygons may touch. + // This range is *exclusive* of the right (max) end, hence why we add 1 + // to the maximum coordinates. + const x0: i32 = @intFromFloat(@floor(polygons.start.x / scale)); + const y0: i32 = @intFromFloat(@floor(polygons.start.y / scale)); + const x1: i32 = @intFromFloat(@floor(polygons.end.x / scale) + 1); + const y1: i32 = @intFromFloat(@floor(polygons.end.y / scale) + 1); - const scaled_sfc = try Surface.init( - .image_surface_alpha8, - arena_alloc, - mask_width, - mask_height, - ); - defer scaled_sfc.deinit(); + const mask_sfc = sfc_m: { + // We calculate a scaled up version of the + // extents for our supersampled drawing. + const box_x0: i32 = x0 * i_scale; + const box_y0: i32 = y0 * i_scale; + const box_x1: i32 = x1 * i_scale; + const box_y1: i32 = y1 * i_scale; + const mask_width: i32 = box_x1 - box_x0; + const mask_height: i32 = box_y1 - box_y0; + const offset_x: i32 = box_x0; + const offset_y: i32 = box_y0; - const poly_y0: i32 = box_y0; - const poly_y1: i32 = box_y1; - var y = poly_y0; - while (y < poly_y1) : (y += 1) { - var edge_list = try polygons.edgesForY(arena_alloc, @floatFromInt(y), fill_rule); - defer edge_list.deinit(); + const scaled_sfc = try Surface.init( + .image_surface_alpha8, + alloc, + mask_width, + mask_height, + ); + errdefer scaled_sfc.deinit(); - var start_idx: usize = 0; - while (start_idx + 1 < edge_list.items.len) { - const start_x = edge_list.items[start_idx]; - const end_x = edge_list.items[start_idx + 1]; + const poly_y0: i32 = box_y0; + const poly_y1: i32 = box_y1; + var y = poly_y0; + while (y < poly_y1) : (y += 1) { + // NOTE: FBA here is "freed" by going out of scope. + var edge_buf: [self.edge_cache_size * @sizeOf(Polygon.Edge)]u8 = undefined; + var edge_fba = heap.FixedBufferAllocator.init(&edge_buf); + var edge_list = try polygons.edgesForY(edge_fba.allocator(), @floatFromInt(y), fill_rule); + while (edge_list.next()) |edge_pair| { + const start_x = edge_pair.start; + const end_x = edge_pair.end; - var x = start_x; - // We fill up to, but not including, the end point. - while (x < end_x) : (x += 1) { - try scaled_sfc.putPixel( - @intCast(x - offset_x), - @intCast(y - offset_y), - .{ .alpha8 = .{ .a = 255 } }, - ); + var x = start_x; + // We fill up to, but not including, the end point. + while (x < end_x) : (x += 1) { + try scaled_sfc.putPixel( + @intCast(x - offset_x), + @intCast(y - offset_y), + .{ .alpha8 = .{ .a = 255 } }, + ); + } + } } - start_idx += 2; - } - } + scaled_sfc.downsample(); + break :sfc_m scaled_sfc; + }; + defer mask_sfc.deinit(); - break :sfc_m try scaled_sfc.downsample(); - }; - defer mask_sfc.deinit(); + // Surface.deinit is not currently idempotent. Given that this is the only + // place where we might double-call deinit at this point, we can just track + // whether or not we need the extra de-init here, versus update the + // interface unnecessarily. + var deinit_fg = false; + const foreground_sfc = sfc_f: { + switch (self.context.pattern) { + // This is the surface that we composite our mask on to get the + // final image that in turn gets composited to the main surface. To + // support proper compositing of the mask, and in turn onto the + // main surface, we use RGBA with our source copied over top (other + // than covered alpha8 special cases below). + // + // NOTE: This is just scaffolding for now, the only pattern we have + // currently is the opaque single-pixel pattern, which is fast-pathed + // below. Once we support things like gradients and what not, we will + // expand this a bit more (e.g., initializing the surface with the + // painted gradient). + .opaque_pattern => { + const px = try self.context.pattern.getPixel(0, 0); + if (px == .alpha8 and px.alpha8.a == 255) { + // Our source pixel is fully opaque alpha. We can + // fast-path here and just use our mask for the + // foreground surface, so do that now, and avoid the + // allocation. + break :sfc_f mask_sfc; + } - const foreground_sfc = switch (self.context.pattern) { - // This is the surface that we composite our mask on to get the final - // image that in turn gets composited to the main surface. To support - // proper compositing of the mask, and in turn onto the main surface, - // we use RGBA with our source copied over top. - // - // NOTE: This is just scaffolding for now, the only pattern we have - // currently is the opaque single-pixel pattern, which is fast-pathed - // below. Once we support things like gradients and what not, we will - // expand this a bit more (e.g., initializing the surface with the - // painted gradient). - .opaque_pattern => try Surface.initPixel( - RGBA.copySrc(try self.context.pattern.getPixel(0, 0)).asPixel(), - arena_alloc, - mask_sfc.getWidth(), - mask_sfc.getHeight(), - ), - }; - defer foreground_sfc.deinit(); + const fg_sfc = try Surface.initPixel( + RGBA.copySrc(try self.context.pattern.getPixel(0, 0)).asPixel(), + alloc, + mask_sfc.getWidth(), + mask_sfc.getHeight(), + ); + errdefer fg_sfc.deinit(); - // Image fully rendered here - try foreground_sfc.dstIn(mask_sfc, 0, 0); + // Image fully rendered here + try fg_sfc.dstIn(mask_sfc, 0, 0); + deinit_fg = true; // Mark foreground for deinit when done + break :sfc_f fg_sfc; + }, + } + }; + defer { + if (deinit_fg) foreground_sfc.deinit(); + } - // Final compositing to main surface - try self.context.surface.srcOver(foreground_sfc, x0, y0); + // Final compositing to main surface + try self.context.surface.srcOver(foreground_sfc, x0, y0); + } + }; } diff --git a/src/internal/Polygon.zig b/src/internal/Polygon.zig index e8334a2..af52c80 100644 --- a/src/internal/Polygon.zig +++ b/src/internal/Polygon.zig @@ -106,8 +106,18 @@ fn concatByCopying(list1: *CornerList, list2: *const CornerList) void { } /// Represents an edge on a polygon for a particular y-scanline. -pub const Edge = struct { - x: i32, +pub const Edge = packed struct { + // The size of our x-edge. + // + // TODO: This ultimately places a limit on our edge size to i30 currently + // (approx. +/- 536870912). This is set up to ensure this struct fits in a + // u32 as this our edges are stored in a very small buffer. + // + // Note that our internal numerics are not 100% yet decided on, so this + // limit may change (and will likely decrease versus increase). + pub const X = i30; + + x: X, dir: i2, pub fn sort_asc(_: void, a: Edge, b: Edge) bool { @@ -154,7 +164,7 @@ pub fn edgesForY(self: *const Polygon, alloc: mem.Allocator, line_y: f64) !std.A (line_y_middle - cur_y) / (last_y - cur_y) * (last_x - cur_x) + cur_x, ); break :edge .{ - .x = math.clamp(@as(i32, @intFromFloat(edge_x)), 0, math.maxInt(i32)), + .x = math.clamp(@as(Edge.X, @intFromFloat(edge_x)), 0, math.maxInt(Edge.X)), // Apply the edge direction to the winding number. // Down-up is +1, up-down is -1. .dir = if (cur_y > last_y) diff --git a/src/internal/PolygonList.zig b/src/internal/PolygonList.zig index e6b52e2..116d22f 100644 --- a/src/internal/PolygonList.zig +++ b/src/internal/PolygonList.zig @@ -45,12 +45,58 @@ pub fn append(self: *PolygonList, poly: Polygon) !void { } } +pub const EdgeListIterator = struct { + index: usize = 0, + edges: []Polygon.Edge, + fill_rule: FillRule, + + pub const EdgePair = struct { + start: i32, + end: i32, + }; + + pub fn next(it: *EdgeListIterator) ?EdgePair { + debug.assert(it.index <= it.edges.len); + if (it.edges.len == 0 or it.index >= it.edges.len - 1) return null; + if (it.fill_rule == .even_odd) { + const start = it.edges[it.index].x; + const end = it.edges[it.index + 1].x; + it.index += 2; + return .{ + .start = start, + .end = end, + }; + } else { + var winding_number: i32 = 0; + var start: i32 = undefined; + while (it.index < it.edges.len) : (it.index += 1) { + if (winding_number == 0) { + start = it.edges[it.index].x; + } + winding_number += @intCast(it.edges[it.index].dir); + if (winding_number == 0) { + const end = it.edges[it.index].x; + it.index += 1; + return .{ + .start = start, + .end = end, + }; + } + } + } + + return null; + } +}; + +/// WARNING: Caller is expected to free the edges returned here manually +/// somehow pub fn edgesForY( self: *const PolygonList, alloc: mem.Allocator, line_y: f64, fill_rule: FillRule, -) !std.ArrayList(i32) { +) !EdgeListIterator { var edge_list = std.ArrayList(Polygon.Edge).init(alloc); defer edge_list.deinit(); @@ -62,42 +108,8 @@ pub fn edgesForY( const edge_list_sorted = try edge_list.toOwnedSlice(); mem.sort(Polygon.Edge, edge_list_sorted, {}, Polygon.Edge.sort_asc); - defer alloc.free(edge_list_sorted); - - // We need to now process our edge list, particularly in the case of - // non-zero fill rules. - // - // TODO: This could probably be optimized by simply returning the edge - // list directly and having the filler work off of that, which would - // remove the need to O(N) copy the edge X-coordinates for even-odd. - // Conversely, orderedRemove in an ArrayList is O(N) and would need to - // be run each time an edge needs to be removed during non-zero rule - // processing. Currently, at the very least, we pre-allocate capacity - // to the incoming sorted edge list. - var final_edge_list = try std.ArrayList(i32).initCapacity(alloc, edge_list_sorted.len); - errdefer final_edge_list.deinit(); - var winding_number: i32 = 0; - var start: i32 = undefined; - if (fill_rule == .even_odd) { - // Just copy all of our edges - the outer filler fills by - // even-odd rule naively, so this is the correct set for that - // method. - for (edge_list_sorted) |e| { - try final_edge_list.append(e.x); - } - } else { - // Go through our edges and filter based on the winding number. - for (edge_list_sorted) |e| { - if (winding_number == 0) { - start = e.x; - } - winding_number += @intCast(e.dir); - if (winding_number == 0) { - try final_edge_list.append(start); - try final_edge_list.append(e.x); - } - } - } - - return final_edge_list; + return .{ + .edges = edge_list_sorted, + .fill_rule = fill_rule, + }; } diff --git a/src/pixel.zig b/src/pixel.zig index 45cdccb..b0a6044 100644 --- a/src/pixel.zig +++ b/src/pixel.zig @@ -77,13 +77,16 @@ pub const RGB = packed struct(u32) { return switch (p) { .rgb => |q| q, .rgba => |q| .{ + // Fully opaque since we drop alpha channel .r = q.r, .g = q.g, .b = q.b, - .a = 255, }, .alpha8 => .{ - .a = 255, + // No color channel data, opaque black + .r = 0, + .g = 0, + .b = 0, }, }; } @@ -221,17 +224,19 @@ pub const RGBA = packed struct(u32) { pub fn copySrc(p: Pixel) RGBA { return switch (p) { .rgb => |q| .{ + // Special case: we assume that RGB pixels are always opaque .r = q.r, .g = q.g, .b = q.b, .a = 255, }, .rgba => |q| q, - .alpha8 => .{ + .alpha8 => |q| .{ + // No color channel data, so opaque black .r = 0, .g = 0, .b = 0, - .a = 255, + .a = q.a, }, }; } @@ -383,6 +388,7 @@ pub const Alpha8 = packed struct(u8) { pub fn copySrc(p: Pixel) Alpha8 { return switch (p) { .rgb => .{ + // Special case: we assume that RGB pixels are always opaque .a = 255, }, .rgba => |q| .{ @@ -563,3 +569,47 @@ test "RGBA, fromClamped" { RGBA.fromClamped(2, 2, 2, 0.5), ); } + +test "copySrc" { + // RGB + try testing.expectEqual( + RGB{ .r = 11, .g = 22, .b = 33 }, + RGB.copySrc(.{ .rgb = .{ .r = 11, .g = 22, .b = 33 } }), + ); + try testing.expectEqual( + RGB{ .r = 11, .g = 22, .b = 33 }, + RGB.copySrc(.{ .rgba = .{ .r = 11, .g = 22, .b = 33, .a = 128 } }), + ); + try testing.expectEqual( + RGB{ .r = 0, .g = 0, .b = 0 }, + RGB.copySrc(.{ .alpha8 = .{ .a = 128 } }), + ); + + // RGBA + try testing.expectEqual( + RGBA{ .r = 11, .g = 22, .b = 33, .a = 255 }, + RGBA.copySrc(.{ .rgb = .{ .r = 11, .g = 22, .b = 33 } }), + ); + try testing.expectEqual( + RGBA{ .r = 11, .g = 22, .b = 33, .a = 128 }, + RGBA.copySrc(.{ .rgba = .{ .r = 11, .g = 22, .b = 33, .a = 128 } }), + ); + try testing.expectEqual( + RGBA{ .r = 0, .g = 0, .b = 0, .a = 128 }, + RGBA.copySrc(.{ .alpha8 = .{ .a = 128 } }), + ); + + // Alpha8 + try testing.expectEqual( + Alpha8{ .a = 255 }, + Alpha8.copySrc(.{ .rgb = .{ .r = 11, .g = 22, .b = 33 } }), + ); + try testing.expectEqual( + Alpha8{ .a = 128 }, + Alpha8.copySrc(.{ .rgba = .{ .r = 11, .g = 22, .b = 33, .a = 128 } }), + ); + try testing.expectEqual( + Alpha8{ .a = 128 }, + Alpha8.copySrc(.{ .alpha8 = .{ .a = 128 } }), + ); +} diff --git a/src/surface.zig b/src/surface.zig index 2884fed..c352b12 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -100,23 +100,13 @@ pub const Surface = union(SurfaceType) { } } - /// Downsamples the image, using simple pixel averaging. The original - /// surface is not altered. + /// Downsamples the image, using simple pixel averaging. /// - /// Uses the same allocator as the original surface. deinit should be - /// called when finished with the surface, which invalidates it, after - /// which it should not be used. - pub fn downsample(self: Surface) !Surface { - // Our initialization process is the same here as init, since we are - // creating a new surface for the downsampled copy. + /// The surface is downsampled in-place. After downsampling, dimensions are + /// altered and memory is freed. + pub fn downsample(self: Surface) void { switch (self) { - inline else => |s, tag| { - const pt = try tag.toPixelType(); - const sfc = try s.alloc.create(ImageSurface(pt)); - errdefer s.alloc.destroy(sfc); - sfc.* = try s.downsample(); - return sfc.asSurfaceInterface(); - }, + inline else => |s| s.downsample(), } } @@ -285,18 +275,14 @@ pub fn ImageSurface(comptime T: type) type { self.alloc.free(self.buf); } - /// Downsamples the image, using simple pixel averaging. The original - /// surface is not altered. + /// Downsamples the image, using simple pixel averaging. /// - /// Uses the same allocator as the original surface. deinit should be - /// called when finished with the surface, which invalidates it, after - /// which it should not be used. - pub fn downsample(self: *ImageSurface(T)) !ImageSurface(T) { + /// The surface is downsampled in-place. After downsampling, dimensions + /// are altered and memory is freed. + pub fn downsample(self: *ImageSurface(T)) void { const scale = supersample_scale; const height: usize = @intCast(@divFloor(self.height, scale)); const width: usize = @intCast(@divFloor(self.width, scale)); - const buf = try self.alloc.alloc(T, height * width); - @memset(buf, mem.zeroes(T)); for (0..@intCast(height)) |y| { for (0..@intCast(width)) |x| { @@ -307,16 +293,14 @@ pub fn ImageSurface(comptime T: type) type { pixels[i * scale + j] = self.buf[idx]; } } - buf[y * width + x] = T.average(&pixels); + self.buf[y * width + x] = T.average(&pixels); } } - - return .{ - .alloc = self.alloc, - .width = @intCast(width), - .height = @intCast(height), - .buf = buf, - }; + self.height = @intCast(height); + self.width = @intCast(width); + if (self.alloc.resize(self.buf, height * width)) { + self.buf = self.buf.ptr[0 .. height * width]; + } } /// Composites the source surface onto this surface using the