Use finer interleave ratios, and fold a mirrored tile at its seam

Two changes to how many colours a texture can reach, and whether they are the
right ones.

A mix alternates its two filaments over a cycle of at most six layers, which
also caps how many distinct mixes exist: eleven ratios per pair of filaments.
Raised to ten, which gives 33 and so lets a gradient be stepped more finely.
Measured on four filaments: a green-to-red gradient goes from 11 usable mixes to
16, and the shipped Colour Bricks from 6 to 10.

Separately, the bilinear neighbour was wrapped for every tiling method. Repeat
does start again at the opposite edge, but a mirrored tile folds back on itself,
so there the texel past the edge is the edge again. Wrapping blended the image's
two opposite edges at every mirrored seam: on a black-to-white ramp the bright
edge sampled as 0.008 instead of 0.988. It also disagreed with the preview,
which binds GL_MIRRORED_REPEAT and folds - so a bake could not match what the
viewport showed.
This commit is contained in:
ExPikaPaka
2026-10-15 08:42:44 +02:00
parent e30b9fe93e
commit edcf865191
4 changed files with 45 additions and 6 deletions
+8 -4
View File
@@ -91,12 +91,16 @@ bool DecodedHeightTexture::texel_tap(const Vec2f &uv, bool tile_enabled, Texture
const float fy = v * float(height);
const int x0 = std::clamp(int(std::floor(fx)), 0, width - 1);
const int y0 = std::clamp(int(std::floor(fy)), 0, height - 1);
// Neighbour for bilinear filtering: wrap for tiling methods, clamp at the edge otherwise (a
// repeating neighbour would incorrectly blend against the opposite edge of the image).
// Neighbour for bilinear filtering. Repeat wraps, because its next tile really does start at the
// opposite edge. The other two clamp: an untiled placement has nothing past its border, and a
// mirrored tile folds back on itself there, so the texel past the edge is the edge again. Wrapping
// a mirrored seam blends the two opposite edges of the image together, which is both a visible
// line and a disagreement with the preview - the GPU path binds GL_MIRRORED_REPEAT, which folds.
const bool wrap_neighbour = tile_enabled && tile_method != TextureTileMethod::MirroredRepeat;
tap.x0 = x0;
tap.y0 = y0;
tap.x1 = tile_enabled ? (x0 + 1) % width : std::min(x0 + 1, width - 1);
tap.y1 = tile_enabled ? (y0 + 1) % height : std::min(y0 + 1, height - 1);
tap.x1 = wrap_neighbour ? (x0 + 1) % width : std::min(x0 + 1, width - 1);
tap.y1 = wrap_neighbour ? (y0 + 1) % height : std::min(y0 + 1, height - 1);
tap.tx = fx - std::floor(fx);
tap.ty = fy - std::floor(fy);
return true;
@@ -286,6 +286,14 @@ constexpr float PREFER_PURE_DE = 2.f;
// the quantizer's lookup cube.
constexpr size_t MIX_TARGET_SAMPLES = size_t(1) << 20;
constexpr size_t MIX_TARGET_BINS = 256;
// The longest interleave cycle a mix may use, as the denominator of its ratio. The slicer alternates
// a mix layer by layer, so the cycle is how many layers one repeat spans; past this it stops reading
// as a colour and starts reading as bands of its two filaments. It also sets how many mixes exist at
// all - the coprime ratios per pair of filaments - so it is what decides how finely a gradient can be
// stepped. Ten gives 33 ratios per pair against the 11 of six: measured on four filaments, a
// green-to-red gradient goes from 11 usable mixes to 16, and the shipped Colour Bricks from 6 to 10.
constexpr int MIX_MAX_DENOMINATOR = 10;
// rank_mixes() stops once the best remaining mix would improve the match by less than this FRACTION
// of what the best mix bought: a mix that only touches a few stray pixels is not worth a filament
// slot. Relative rather than a fixed amount of CIEDE2000, because the gains decay smoothly and a
@@ -5355,7 +5363,7 @@ std::vector<GLGizmoTextureDisplacement::PaletteEntry> GLGizmoTextureDisplacement
std::vector<PaletteEntry> candidates;
for (int i = 0; i < n; ++i)
for (int j = i + 1; j < n; ++j)
for (int den = 2; den <= 6; ++den)
for (int den = 2; den <= MIX_MAX_DENOMINATOR; ++den)
for (int num = 1; num < den; ++num) {
if (std::gcd(num, den) != 1)
continue; // 2/4 is 1/2, already there
@@ -103,7 +103,7 @@ public:
// actually pick it. Stops early once another mix would make no noticeable difference, so a texture
// the filaments already cover gets few mixes or none.
//
// Ratios are the short-cycle ones (k/d for d up to 6): the slicer interleaves a mix layer by layer,
// Ratios are k/d for d up to MIX_MAX_DENOMINATOR: the slicer interleaves a mix layer by layer,
// and a long cycle prints as visible bands rather than as a colour. A mix's `rgb` is the colour its
// mixed filament slot will show (blend_color_multi(), as the sidebar computes it), so the match, the
// preview and the slot all agree on what the mix looks like.
@@ -2653,6 +2653,33 @@ TEST_CASE("TextureDisplacement: a texture of flat colours is told apart from a c
CHECK(d.flat_share < 0.85f);
}
TEST_CASE("A mirrored tile folds at its seam instead of wrapping to the far edge", "[TextureDisplacement]")
{
// Mirrored repeat exists so a tile edge lines up with itself, which means the texel past the edge
// is the edge again. Wrapping the bilinear neighbour instead blends the image's two opposite edges
// together: on a ramp from black to white it turned the white edge into near-black at every seam,
// a visible line and a disagreement with the preview, where the GPU binds GL_MIRRORED_REPEAT.
TextureDisplacementLayer layer;
layer.image_data = make_rgb_png(8, 8, [](size_t x, size_t) {
const uint8_t v = uint8_t(x * 36); // 0 at the left edge, 252 at the right
return std::array<uint8_t, 3>{ v, v, v };
});
const DecodedHeightTexture tex = decode_height_texture(layer);
REQUIRE(tex.width == 8);
const float bright = 252.f / 255.f;
// Either side of a seam the fold puts the bright edge, not a blend with the dark one.
for (const float u : { 0.999f, 1.001f, 2.999f, 3.001f }) {
INFO("u = " << u);
CHECK_THAT(tex.sample(Vec2f(u, 0.5f), true, TextureTileMethod::MirroredRepeat),
WithinAbs(bright, 0.02f));
}
// Plain repeat is the opposite case and must keep wrapping: its next tile really does start at the
// far edge, so just inside the seam the neighbour is the dark one.
CHECK(tex.sample(Vec2f(0.999f, 0.5f), true, TextureTileMethod::Repeat) < 0.1f);
}
TEST_CASE("A two-colour ramp is not mistaken for an image of flat colours", "[TextureDisplacement]")
{
// The histogram alone cannot tell these two apart: a ramp between two colours visits as few