fix(canvas): prevent texture atlas sampling bleed

This commit is contained in:
Chris Tate
2026-08-18 09:39:57 -05:00
parent 66b4d50d25
commit 27cba0ec3b
8 changed files with 117 additions and 22 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ A loaded image occupies one of the registry's **16 slots** until you release it,
Unlike a load, unregister is **synchronous registry surgery, not an effect**: no result Msg follows (registration by `imageLoad` has a terminal because I/O and decode can fail; releasing a slot cannot), and aimed at an id with no registration it no-ops — `imageCancel`'s idle rule. It frees only the **current** registration: a load in flight under the id is untouched, and its terminal still registers the pixels, re-occupying the id. To evict an id whose load is still running, `Cmd.imageCancel(id)` first, then unregister.
When many small visuals ship together, one slot can hold a texture atlas instead: `<image>` and `<avatar>` accept `source-x`, `source-y`, `source-width`, and `source-height` together, in decoded-image pixel coordinates. Zig views set the equivalent `ElementOptions.image_src` rectangle. Every atlas region references the same registered ImageId, so it consumes one registry slot; use the dimensions reported by the load result because decode-to-fit may change the atlas geometry.
When many small visuals ship together, one slot can hold a texture atlas instead: `<image>` and `<avatar>` accept `source-x`, `source-y`, `source-width`, and `source-height` together, in decoded-image pixel coordinates. Zig views set the equivalent `ElementOptions.image_src` rectangle. Cropped widgets use nearest sampling so adjacent regions cannot bleed into the tile. Every atlas region references the same registered ImageId, so it consumes one registry slot; use the dimensions reported by the load result because decode-to-fit may change the atlas geometry.
## Limits, honestly
+1 -1
View File
@@ -573,7 +573,7 @@ ui.image(.{ .image = model.chart_image, .width = 120, .height = 80, .semantics =
<avatar image="{avatar_image}" label="Octocat">OC</avatar>
```
One registered image can be a texture atlas. `ElementOptions.image_src` is an optional `geometry.RectF` in decoded-image pixel coordinates; Native markup spells the same rectangle with all four source attributes. The widget frame stays the destination, and a source rectangle crossing the registered image bounds is clipped:
One registered image can be a texture atlas. `ElementOptions.image_src` is an optional `geometry.RectF` in decoded-image pixel coordinates; Native markup spells the same rectangle with all four source attributes. The widget frame stays the destination, a source rectangle crossing the registered image bounds is clipped, and cropped widgets use nearest sampling so adjacent atlas regions cannot bleed into the tile:
```zig
ui.image(.{
+2 -1
View File
@@ -981,7 +981,8 @@ The view binds that model-owned id in either tier; `0` is the no-image sentinel
One registered image can be a texture atlas. The source rectangle uses
decoded-image pixel coordinates; declare all four markup attributes, or set
the equivalent `ElementOptions.image_src` `geometry.RectF` in a Zig view:
the equivalent `ElementOptions.image_src` `geometry.RectF` in a Zig view.
Cropped widgets use nearest sampling so adjacent atlas regions cannot bleed:
```html
<image image="{atlas}" source-x="64" source-y="32"
+37 -15
View File
@@ -486,6 +486,7 @@ pub const ReferenceRenderSurface = struct {
} else return error.ReferenceRenderUnsupportedCommand;
const src_rect = referenceImageSourceRect(image, value.src) orelse return;
const sample_bounds = referenceImageSampleBounds(image, src_rect);
const local_dst = referenceImageDestinationRect(value.dst, src_rect, value.fit) orelse return;
const dst_rect = command.transform.transformRect(local_dst).normalized();
// The rounded mask applies over the REQUESTED destination (the
@@ -510,7 +511,7 @@ pub const ReferenceRenderSurface = struct {
// once per (image content, size, phase) and every later repaint
// — the cover-loading cascade, a re-opened view, a whole-pixel
// move — blends from the panel.
if (self.imageScalePanel(image, value, src_rect, dst_rect, pixel_rect)) |panel| {
if (self.imageScalePanel(image, value, src_rect, sample_bounds, dst_rect, pixel_rect)) |panel| {
const dst_x0: i64 = @intFromFloat(@floor(dst_rect.x));
const dst_y0: i64 = @intFromFloat(@floor(dst_rect.y));
var y = pixel_rect.y;
@@ -549,7 +550,7 @@ pub const ReferenceRenderSurface = struct {
if (has_mask and !referencePointInRoundedRect(point, mask_rect, mask_radius)) continue;
const u = std.math.clamp((point.x - dst_rect.x) / dst_rect.width, 0, 1);
const v = std.math.clamp((point.y - dst_rect.y) / dst_rect.height, 0, 1);
const sample = referenceSampleImage(image, src_rect, u, v, value.sampling);
const sample = referenceSampleImage(image, src_rect, sample_bounds, u, v, value.sampling);
const index = (y * self.width + x) * 4;
const dst = [4]u8{
self.pixels[index + 0],
@@ -575,7 +576,7 @@ pub const ReferenceRenderSurface = struct {
width: usize,
};
fn imageScalePanel(self: ReferenceRenderSurface, image: ReferenceImage, value: DrawImage, src_rect: geometry.RectF, dst_rect: geometry.RectF, pixel_rect: ReferencePixelRect) ?ImageScalePanel {
fn imageScalePanel(self: ReferenceRenderSurface, image: ReferenceImage, value: DrawImage, src_rect: geometry.RectF, sample_bounds: ReferenceImageSampleBounds, dst_rect: geometry.RectF, pixel_rect: ReferencePixelRect) ?ImageScalePanel {
const memo = self.render_memo orelse return null;
// Exact-arithmetic bounds: pixel offsets and phases must stay in
// f32's exact-integer range for the phase-relative identity
@@ -630,7 +631,7 @@ pub const ReferenceRenderSurface = struct {
var column: usize = 0;
while (column < panel_width) : (column += 1) {
const u = std.math.clamp(((@as(f32, @floatFromInt(column)) + 0.5) - phase_x) / dst_rect.width, 0, 1);
const sample = referenceSampleImage(image, src_rect, u, v, value.sampling);
const sample = referenceSampleImage(image, src_rect, sample_bounds, u, v, value.sampling);
const offset = (row * panel_width + column) * 4;
buffer[offset] = sample[0];
buffer[offset + 1] = sample[1];
@@ -1372,22 +1373,43 @@ const ReferencePremultipliedLinearColor = struct {
a: f32 = 0,
};
fn referenceSampleImage(image: ReferenceImage, src: geometry.RectF, u: f32, v: f32, sampling: ImageSampling) [4]u8 {
fn referenceSampleImage(image: ReferenceImage, src: geometry.RectF, bounds: ReferenceImageSampleBounds, u: f32, v: f32, sampling: ImageSampling) [4]u8 {
return switch (sampling) {
.nearest => referenceSampleImageNearest(image, src, u, v),
.linear => referenceSampleImageLinear(image, src, u, v),
.nearest => referenceSampleImageNearest(image, src, bounds, u, v),
.linear => referenceSampleImageLinear(image, src, bounds, u, v),
};
}
fn referenceSampleImageNearest(image: ReferenceImage, src: geometry.RectF, u: f32, v: f32) [4]u8 {
const ReferenceImageSampleBounds = struct {
min_x: i32,
min_y: i32,
max_x: i32,
max_y: i32,
};
/// Inclusive texel bounds touched by a clipped source rectangle. Native
/// image APIs constrain filtering to their source portion; mirror that
/// here so scaling a texture-atlas tile never samples an adjacent tile.
fn referenceImageSampleBounds(image: ReferenceImage, src: geometry.RectF) ReferenceImageSampleBounds {
const image_max_x: i32 = @intCast(image.width - 1);
const image_max_y: i32 = @intCast(image.height - 1);
return .{
.min_x = clampI32(referenceFloor(src.minX()), 0, image_max_x),
.min_y = clampI32(referenceFloor(src.minY()), 0, image_max_y),
.max_x = clampI32(referenceCeil(src.maxX()) - 1, 0, image_max_x),
.max_y = clampI32(referenceCeil(src.maxY()) - 1, 0, image_max_y),
};
}
fn referenceSampleImageNearest(image: ReferenceImage, src: geometry.RectF, bounds: ReferenceImageSampleBounds, u: f32, v: f32) [4]u8 {
const sample_x_f = src.x + std.math.clamp(u, 0, 1) * src.width;
const sample_y_f = src.y + std.math.clamp(v, 0, 1) * src.height;
const x = clampI32(referenceFloor(sample_x_f), 0, @intCast(image.width - 1));
const y = clampI32(referenceFloor(sample_y_f), 0, @intCast(image.height - 1));
const x = clampI32(referenceFloor(sample_x_f), bounds.min_x, bounds.max_x);
const y = clampI32(referenceFloor(sample_y_f), bounds.min_y, bounds.max_y);
return referenceImagePixel(image, x, y);
}
fn referenceSampleImageLinear(image: ReferenceImage, src: geometry.RectF, u: f32, v: f32) [4]u8 {
fn referenceSampleImageLinear(image: ReferenceImage, src: geometry.RectF, bounds: ReferenceImageSampleBounds, u: f32, v: f32) [4]u8 {
// Belt over the renderPass-level fill: direct sampler callers (unit
// tests, future paths) stay correct. One predictable branch per
// output pixel — noise next to the twelve pows the table replaces.
@@ -1396,10 +1418,10 @@ fn referenceSampleImageLinear(image: ReferenceImage, src: geometry.RectF, u: f32
const sample_y_f = src.y + std.math.clamp(v, 0, 1) * src.height - 0.5;
const x_floor = referenceFloor(sample_x_f);
const y_floor = referenceFloor(sample_y_f);
const x0 = clampI32(x_floor, 0, @intCast(image.width - 1));
const y0 = clampI32(y_floor, 0, @intCast(image.height - 1));
const x1 = clampI32(x_floor + 1, 0, @intCast(image.width - 1));
const y1 = clampI32(y_floor + 1, 0, @intCast(image.height - 1));
const x0 = clampI32(x_floor, bounds.min_x, bounds.max_x);
const y0 = clampI32(y_floor, bounds.min_y, bounds.max_y);
const x1 = clampI32(x_floor + 1, bounds.min_x, bounds.max_x);
const y1 = clampI32(y_floor + 1, bounds.min_y, bounds.max_y);
const tx = std.math.clamp(sample_x_f - @as(f32, @floatFromInt(x_floor)), 0, 1);
const ty = std.math.clamp(sample_y_f - @as(f32, @floatFromInt(y_floor)), 0, 1);
+63
View File
@@ -1731,6 +1731,69 @@ test "reference renderer bilinear-filters scaled images" {
try expectPixelRgba8(.{ 255, 255, 255, 255 }, surface, 3, 3);
}
test "reference renderer keeps linear atlas crops inside their source rectangle" {
const commands = [_]CanvasCommand{.{ .draw_image = .{
.id = 1,
.image_id = 42,
.src = geometry.RectF.init(1, 1, 2, 2),
.dst = geometry.RectF.init(0, 0, 4, 4),
} }};
// A green 2x2 tile surrounded by red atlas neighbors. Scaling the
// crop 2x must stay green through its edge pixels; full-image clamp
// would blend red into every side through the bilinear taps.
var image_pixels: [4 * 4 * 4]u8 = undefined;
for (0..16) |index| {
image_pixels[index * 4 + 0] = 255;
image_pixels[index * 4 + 1] = 0;
image_pixels[index * 4 + 2] = 0;
image_pixels[index * 4 + 3] = 255;
}
for (1..3) |y| {
for (1..3) |x| {
const index = (y * 4 + x) * 4;
image_pixels[index + 0] = 0;
image_pixels[index + 1] = 255;
image_pixels[index + 2] = 0;
}
}
const images = [_]ReferenceImage{.{
.id = 42,
.width = 4,
.height = 4,
.pixels = &image_pixels,
}};
var render_commands: [1]RenderCommand = undefined;
var render_batches: [1]RenderBatch = undefined;
var resources: [1]RenderResource = undefined;
var resource_cache_entries: [1]RenderResourceCacheEntry = undefined;
var resource_cache_actions: [1]RenderResourceCacheAction = undefined;
var glyphs: [0]GlyphAtlasEntry = .{};
var changes: [0]DiffChange = .{};
const frame = try (DisplayList{ .commands = &commands }).framePlan(null, .{
.surface_size = geometry.SizeF.init(4, 4),
}, .{
.render_commands = &render_commands,
.render_batches = &render_batches,
.resources = &resources,
.resource_cache_entries = &resource_cache_entries,
.resource_cache_actions = &resource_cache_actions,
.glyph_atlas_entries = &glyphs,
.changes = &changes,
});
var pixels: [4 * 4 * 4]u8 = undefined;
const surface = (try ReferenceRenderSurface.init(4, 4, &pixels)).withImages(&images);
try surface.renderPass(frame.renderPass(), Color.rgb8(0, 0, 0));
for (0..4) |y| {
for (0..4) |x| {
try expectPixelRgba8(.{ 0, 255, 0, 255 }, surface, x, y);
}
}
}
test "reference renderer nearest-filters scaled images" {
const commands = [_]CanvasCommand{.{ .draw_image = .{
.id = 1,
+3 -1
View File
@@ -583,7 +583,9 @@ pub fn Ui(comptime Msg: type) type {
/// image-bearing widgets. Null draws the whole registered
/// image; a rectangle draws only that sub-region, clipped to
/// the registered image bounds — the texture-atlas path.
/// The destination remains the widget's resolved frame.
/// Crops use nearest sampling so filtering cannot bleed an
/// adjacent atlas region. The destination remains the
/// widget's resolved frame.
image_src: ?geometry.RectF = null,
/// Vector icon name drawn inside icon-bearing controls
/// (`button`, `toggle_button`, `icon_button`, `list_item`,
+7 -2
View File
@@ -2697,7 +2697,10 @@ fn emitImageWidget(builder: *Builder, widget: Widget) Error!void {
.dst = widget.frame,
.opacity = widget.image_opacity,
.fit = widget.image_fit,
.sampling = widget.image_sampling,
// Packet hosts expose filtering but no per-draw sampler-address
// mode. Nearest sampling keeps an atlas crop from filtering
// across its source boundary; whole-image draws stay linear.
.sampling = if (widget.image_src != null) .nearest else widget.image_sampling,
});
if (clips_image) try builder.popClip();
}
@@ -2987,7 +2990,9 @@ fn emitAvatarWidget(builder: *Builder, widget: Widget, tokens: DesignTokens) Err
.dst = widget.frame,
.opacity = widget.image_opacity,
.fit = widget.image_fit,
.sampling = widget.image_sampling,
// See emitImageWidget: a cropped avatar is an atlas draw and
// must not sample neighboring regions on packet hosts.
.sampling = if (widget.image_src != null) .nearest else widget.image_sampling,
// The render plan flattens the clip stack to rects, so the
// pill clip above only crops the bounds; the draw's own
// radius mask is what actually rounds the image.
@@ -829,7 +829,7 @@ test "widget image emits draw image and exposes image semantics" {
.image_id = 42,
.image_src = geometry.RectF.init(0, 0, 320, 192),
.image_fit = .cover,
.image_sampling = .nearest,
.image_sampling = .linear,
.image_opacity = 0.75,
.semantics = .{ .label = "Deployment preview" },
};
@@ -864,6 +864,8 @@ test "widget image emits draw image and exposes image semantics" {
try expectRect(geometry.RectF.init(0, 0, 320, 192), draw.src);
try expectRect(geometry.RectF.init(12, 14, 80, 48), draw.dst);
try std.testing.expectEqual(ImageFit.cover, draw.fit);
// Atlas crops force nearest sampling at the widget seam so
// packet hosts cannot filter outside the source rectangle.
try std.testing.expectEqual(ImageSampling.nearest, draw.sampling);
try std.testing.expectEqual(@as(f32, 0.75), draw.opacity);
},