fix(canvas): prevent texture atlas sampling bleed
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(.{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user