LibGfx+LibWeb: Use mipmaps for downscaling images

This changes Gfx::ScalingMode to reflect the three modes of scaling we
support using Skia, which makes it a bit easier to reason about the mode
to select. New is ::BilinearMipmap, which uses linear interpolation
between mipmap levels to produce higher quality downscaled images.

The cubic resampling options Mitchell and its sibling CatmullRom both
produced weird artifacts or resulted in a worse quality than
BilinearMipmap when downscaling. We might not have been using these
correctly, but the new ::BilinearMipmap method seems to mirror what
Chrome uses for downscaled images.
This commit is contained in:
Jelle Raaijmakers 2025-11-11 16:43:41 +01:00 committed by Alexander Kalenik
parent 7544066c0c
commit 3f6cbeb87e
Notes: github-actions[bot] 2025-11-12 15:00:21 +00:00
23 changed files with 66 additions and 29 deletions

View file

@ -1,5 +1,6 @@
/*
* Copyright (c) 2024, Andreas Kling <andreas@ladybird.org>
* Copyright (c) 2025, Jelle Raaijmakers <jelle@ladybird.org>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
@ -9,11 +10,10 @@
namespace Gfx {
enum class ScalingMode {
NearestNeighbor,
SmoothPixels,
BilinearBlend,
BoxSampling,
None,
Bilinear,
BilinearMipmap,
NearestNeighbor,
};
}

View file

@ -118,19 +118,19 @@ constexpr SkPathFillType to_skia_path_fill_type(Gfx::WindingRule winding_rule)
VERIFY_NOT_REACHED();
}
constexpr SkSamplingOptions to_skia_sampling_options(Gfx::ScalingMode scaling_mode)
constexpr SkSamplingOptions to_skia_sampling_options(ScalingMode scaling_mode)
{
switch (scaling_mode) {
case Gfx::ScalingMode::NearestNeighbor:
case Gfx::ScalingMode::SmoothPixels:
return SkSamplingOptions(SkFilterMode::kNearest);
case Gfx::ScalingMode::BilinearBlend:
case ScalingMode::None:
return SkSamplingOptions();
case ScalingMode::Bilinear:
return SkSamplingOptions(SkFilterMode::kLinear);
case Gfx::ScalingMode::BoxSampling:
return SkSamplingOptions(SkCubicResampler::Mitchell());
default:
VERIFY_NOT_REACHED();
case ScalingMode::BilinearMipmap:
return SkSamplingOptions(SkFilterMode::kLinear, SkMipmapMode::kLinear);
case ScalingMode::NearestNeighbor:
return SkSamplingOptions(SkFilterMode::kNearest);
}
VERIFY_NOT_REACHED();
}
SkPath to_skia_path(Path const& path);

View file

@ -455,17 +455,19 @@ struct TextDecorationThickness {
// FIXME: Find a better place for this helper.
inline Gfx::ScalingMode to_gfx_scaling_mode(ImageRendering css_value, Gfx::IntRect source, Gfx::IntRect target)
{
if (source.size() == target.size())
return Gfx::ScalingMode::None;
switch (css_value) {
case ImageRendering::Auto:
case ImageRendering::HighQuality:
case ImageRendering::Smooth:
if (target.width() < source.width() || target.height() < source.height())
return Gfx::ScalingMode::BoxSampling;
return Gfx::ScalingMode::BilinearBlend;
if (target.width() < source.width() && target.height() < source.height())
return Gfx::ScalingMode::BilinearMipmap;
return Gfx::ScalingMode::Bilinear;
case ImageRendering::CrispEdges:
return Gfx::ScalingMode::NearestNeighbor;
case ImageRendering::Pixelated:
return Gfx::ScalingMode::SmoothPixels;
return Gfx::ScalingMode::NearestNeighbor;
}
VERIFY_NOT_REACHED();
}

View file

@ -188,7 +188,7 @@ WebIDL::ExceptionOr<void> CanvasRenderingContext2D::draw_image_internal(CanvasIm
auto scaling_mode = Gfx::ScalingMode::NearestNeighbor;
if (drawing_state().image_smoothing_enabled) {
// FIXME: Honor drawing_state().image_smoothing_quality
scaling_mode = Gfx::ScalingMode::BilinearBlend;
scaling_mode = Gfx::ScalingMode::BilinearMipmap;
}
if (auto* painter = this->painter()) {

View file

@ -214,11 +214,11 @@ static ErrorOr<NonnullRefPtr<Gfx::Bitmap>> crop_to_the_source_rectangle_with_for
// The "high" value indicates a preference for a high level of image interpolation quality. High-quality image interpolation may be more computationally expensive than lower settings.
case Bindings::ResizeQuality::Medium:
// The "medium" value indicates a preference for a medium level of image interpolation quality.
scaling_passes.append(ScalingPass { .mode = Gfx::ScalingMode::BoxSampling, .width = output_width, .height = output_height });
scaling_passes.append(ScalingPass { .mode = Gfx::ScalingMode::BilinearMipmap, .width = output_width, .height = output_height });
break;
case Bindings::ResizeQuality::Low:
// The "low" value indicates a preference for a low level of image interpolation quality. Low-quality image interpolation may be more computationally efficient than higher settings.
scaling_passes.append(ScalingPass { .mode = Gfx::ScalingMode::BilinearBlend, .width = output_width, .height = output_height });
scaling_passes.append(ScalingPass { .mode = Gfx::ScalingMode::Bilinear, .width = output_width, .height = output_height });
break;
case Bindings::ResizeQuality::Pixelated: {
// The "pixelated" value indicates a preference for scaling the image to preserve the pixelation of the original as much as possible, with minor smoothing as necessary to avoid distorting the image when the target size is not a clean multiple of the original.
@ -237,7 +237,7 @@ static ErrorOr<NonnullRefPtr<Gfx::Bitmap>> crop_to_the_source_rectangle_with_for
scaling_passes.append(ScalingPass { .mode = Gfx::ScalingMode::NearestNeighbor, .width = source_width * width_multiple, .height = source_height * height_multiple });
// then scale it the rest of the way to the target size using bilinear interpolation.
scaling_passes.append(ScalingPass { .mode = Gfx::ScalingMode::BilinearBlend, .width = output_width, .height = output_height });
scaling_passes.append(ScalingPass { .mode = Gfx::ScalingMode::Bilinear, .width = output_width, .height = output_height });
} break;
}
for (ScalingPass& scaling_pass : scaling_passes) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -8,4 +8,4 @@
background-color: white;
}
</style>
<img src="../images/canvas-filters.png">
<img src="../images/canvas-filters-ref.png">

View file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<style>
* {
margin: 0;
}
body {
background-color: cyan;
}
</style>
<!-- To rebase:
1. Open image-unpremultiplied-data.html in Ladybird
2. Resize the window just above the width of the largest element
3. Right click > "Take Full Screenshot"
4. Update the image below:
-->
<img src="../images/image-downscaling-ref.png">

View file

@ -13,4 +13,4 @@
2. Right click > "Take Full Screenshot"
3. Update the image below:
-->
<img src="../images/object-fit-position.png">
<img src="../images/object-fit-position-ref.png">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 328 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 KiB

View file

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<link rel="match" href="../expected/border-radius-ref.html" />
<meta name="fuzzy" content="maxDifference=0-8;totalPixels=0-28801">
<meta name="fuzzy" content="maxDifference=0-8;totalPixels=0-28873">
<head>
<style>
.box {

View file

@ -1,6 +1,6 @@
<!DOCTYPE html>
<link rel="match" href="../expected/canvas-filters-ref.html"/>
<meta name="fuzzy" content="maxDifference=0-6;totalPixels=0-17784">
<meta name="fuzzy" content="maxDifference=0-6;totalPixels=0-17540">
<style>
* {
margin: 0;

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<title>CSS Background Tests</title>
<link rel="match" href="../expected/css-backgrounds-ref.html" />
<meta name="fuzzy" content="maxDifference=0-21;totalPixels=0-36803">
<meta name="fuzzy" content="maxDifference=0-21;totalPixels=0-72032">
<style>
.box {
width: 180px;

View file

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<link rel="match" href="../expected/css-filter-ref.html" />
<meta name="fuzzy" content="maxDifference=0-1;totalPixels=0-1063">
<meta name="fuzzy" content="maxDifference=0-2;totalPixels=0-1396">
<style>
body {
font-size: 0;

View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<link rel="match" href="../expected/image-downscaling-ref.html" />
<meta name="fuzzy" content="maxDifference=0-1;totalPixels=0-1">
<style>
#a {
width: 40px;
}
#b {
height: 40px;
width: 80px;
}
#c {
height: 80px;
width: 20px;
}
</style>
<img id="a" src="../data/angled-stripes.png">
<img id="b" src="../data/angled-stripes.png">
<img id="c" src="../data/angled-stripes.png">

View file

@ -1,6 +1,6 @@
<!DOCTYPE html>
<link rel="match" href="../expected/object-fit-position-ref.html"/>
<meta name="fuzzy" content="maxDifference=0-1;totalPixels=0-316">
<meta name="fuzzy" content="maxDifference=0-1;totalPixels=0-465">
<style>
.images img {
border: 1px solid black;