From 01578c841a8a8920aa537eb5d84f004fff60ca30 Mon Sep 17 00:00:00 2001 From: yuuko Date: Tue, 12 May 2026 20:45:45 -0700 Subject: [PATCH 1/8] vtfpp: ImageConversion::alphaToDistance: init --- include/vtfpp/ImageConversion.h | 46 +++ src/vtfpp/ImageConversion.cpp | 518 ++++++++++++++++++++++++++++++++ 2 files changed, 564 insertions(+) diff --git a/include/vtfpp/ImageConversion.h b/include/vtfpp/ImageConversion.h index 430e6df2..cb520773 100644 --- a/include/vtfpp/ImageConversion.h +++ b/include/vtfpp/ImageConversion.h @@ -139,4 +139,50 @@ void invertGreenChannelForImageData(std::span imageData, ImageFormat /// Perform Hable tonemapping on the given image data void hableTonemapImageData(std::span imageData, ImageFormat format, uint16_t width, uint16_t height); +enum class DistanceDither { + NONE = 0, ///< No dithering. + GRADIENT_TANGENT, ///< Experimental dithering approach that diffuses quantization error perpendicular to the distance gradient at any destination pixel. Slow and requires up to double the memory. + // anything generic that doesn't depend on the geometry of the distance field (floyd-steinberg etc) would belong somewhere more general. +}; + +enum class DistanceFlags : uint32_t { + NONE = 0, + DISTANCEAA = 1 << 0, ///< Experimental; interpret the alpha channel as antialiased. Can result in a more precise distance map, but produces nonsense if large gradients are present. + EUCLIDEAN = 1 << 1, ///< The distance-mapping algorithm is a brute-force scan of a *square* area. If this is enabled, only accept distance hits in a circular area. + SAMPLECENTERED = 1 << 2, ///< Search from the center of pixels (in destination coordinate space) rather than in their north-west corners. Can mitigate a perceived southeast shift at extreme reductions. +}; + +SOURCEPP_BITFLAGS_ENUM(DistanceFlags) + +/// In one operation, convert an image's alpha channel, or, for single-channel formats, its only channel, to a VTEX-style distance map, and downscale other channels, if present in the output, according to the given resize parameters. +/// @return Empty if: +/// * \p inFormat or \p outFormat do not satisy their documented predicates, +/// * \p reduceX or \p reduceY are not powers of two, +/// * \p distanceSpread would result in a search radius of zero with either \p reduceX or \p reduceY, +/// * \p alphaThreshold is out of range, or +/// * \p edge is ResizeEdge::ZERO. +/// +/// Otherwise, an image at \p width / \p reduceX by \p height / \p reduceY in \p outFormat: +/// * if \p outFormat is single-channel, R contains the distance map. +/// * if \p outFormat has alpha, A contains the distance map, while R/G/B contain: +/// * if \p inFormat had R/G/B, the scaled R/G/B of the input image, otherwise +/// * 0 in all other channels. +[[nodiscard]] std::vector alphaToDistance( + std::span imageData, + ImageFormat inFormat, ///< Any format that is either single-channel, or has an alpha channel. + ImageFormat outFormat, ///< The same requirements as inFormat; channel count does not need to correspond to inFormat. + uint16_t width, + uint16_t height, + uint16_t reduceX, ///< Power-of-two horizontal reduction factor. + uint16_t reduceY, ///< Power-of-two vertical reduction factor. + bool srgb, ///< Used in the case of RGBA->RGBA. premultipliedAlpha is omitted. + float distanceSpread = 1.0f, ///< Scale factor beyond \p reduceX and \p reduceY, of search radius for distance hits. + float alphaThreshold = 0.04f, ///< Threshold below which alpha values are considered zero. + DistanceFlags flags = DistanceFlags::NONE, ///< Additional behaviors that deviate from VTEX but may be useful; see DistanceFlags. + DistanceDither dither = DistanceDither::NONE, ///< Internally, distance maps are always computed in floating point. When set to a value other than NONE, and the output format is of integral type, dithering is applied prior to quantization. Depending on the application and contents, this can improve or worsen the quality of the distance map. + ResizeFilter filter = ResizeFilter::NICE, ///< Default value mimics VTEX. Does not affect the alpha channel; distance mapping is a distinct sampling operation from source to destination space. Entirely unused if input is single-channel. + ResizeEdge edge = ResizeEdge::CLAMP, ///< Dictates the sampling policy of the distance function regardless of whether there are non-alpha channels to be resized. + bool *valveQuirks = nullptr ///< When non-null, mimic VTEX's policy of blanking out any edge pixels in the distance map to 0 (i.e. infinite distance), and report back whether this resulted in any change (such that a command-line tool emulating VTEX would want to report a warning). +); + } // namespace vtfpp::ImageConversion diff --git a/src/vtfpp/ImageConversion.cpp b/src/vtfpp/ImageConversion.cpp index b4eded89..171305af 100644 --- a/src/vtfpp/ImageConversion.cpp +++ b/src/vtfpp/ImageConversion.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #ifdef SOURCEPP_BUILD_WITH_TBB #include @@ -2376,3 +2377,520 @@ void ImageConversion::hableTonemapImageData(std::span imageData, Imag #undef VTFPP_TONEMAP_CASE } + +namespace vtfpp::detail::DistanceMapping { +namespace { + +using math::Vec2i; +using math::Vec2f32; + +enum Leaf : uint8_t { + IN, // leaf is opaque + OUT, // leaf is transparent + FIGUREITOUT, // leaf is mixed but small so you should brute force scan. +}; + +uint16_t i32tou16sat(int32_t i, int32_t cap = UINT16_MAX) { + return static_cast(std::clamp(i, 0, cap)); +} + +uint16_t ftoabsu16(float f) { + return i32tou16sat(static_cast(fabs(f))); +} + +using IndexFunction = std::function; + +int32_t indexClamped(Vec2i v, uint16_t w, uint16_t h, uint8_t pxLen, uint8_t alphaOffs) { + int32_t rX = i32tou16sat(v[0], w - 1); + int32_t rY = i32tou16sat(v[1], h - 1); + return (rY * w + rX) * pxLen + alphaOffs; +} + +int32_t indexReflected(Vec2i v, uint16_t w, uint16_t h, uint8_t pxLen, uint8_t alphaOffs) { + int32_t rX = v[0] >= w ? w - v[0] % w : v[0]; + int32_t rY = v[1] >= h ? h - v[1] % h : v[1]; + return (rY * w + rX) * pxLen + alphaOffs; +} + +int32_t indexWrapped(Vec2i v, uint16_t w, uint16_t h, uint8_t pxLen, uint8_t alphaOffs) { + int32_t rX = v[0] % w; + int32_t rY = v[1] % h; + return (rY * w + rX) * pxLen + alphaOffs; +} + +IndexFunction indexBy(ImageConversion::ResizeEdge edge) +{ + switch (edge) { + default: + break; + case ImageConversion::ResizeEdge::REFLECT: + return indexReflected; + case ImageConversion::ResizeEdge::WRAP: + return indexWrapped; + } + return indexClamped; +} + +// big ugly immutable record of everything pertaining to how we read alpha from an image +class SampleParam { +public: + SampleParam( + const float *srcImg, + uint16_t inWidth, + uint16_t inHeight, + uint16_t reduceX, + uint16_t reduceY, + uint8_t pxLen, + uint8_t alphaOffs, + float distanceSpread, + bool sampleCentered, + float alphaThreshold, + ImageConversion::ResizeEdge edge + ) + : srcImg(srcImg) + , pxLen(pxLen) + , alphaOffs(alphaOffs) + , imgWidth(inWidth) + , imgHeight(inHeight) + , reduceX(reduceX) + , reduceY(reduceY) + , searchRadius(ftoabsu16(2.0f * std::max(reduceX, reduceY) * distanceSpread)) + // the second power of two after search radius + , granularity(uint16_t{4} << ftoabsu16(ceil(log2(static_cast(this->searchRadius))))) + , offsX(sampleCentered ? reduceX / 2 : 0) + , offsY(sampleCentered ? reduceY / 2 : 0) + , alphaThreshold(alphaThreshold) + , indexFn(indexBy(edge)) + {} + + float sample(Vec2i v) const { + return this->srcImg[this->indexFn(v, this->imgWidth, this->imgHeight, this->pxLen, this->alphaOffs)]; + } + + Leaf thresh(float alpha) const { + return alpha >= this->alphaThreshold ? IN : OUT; + } + + const float *const srcImg; + const float alphaThreshold; + const uint16_t imgHeight; + const uint16_t imgWidth; + const uint16_t searchRadius; + const uint16_t granularity; + const uint16_t offsX, offsY; + const uint16_t reduceX, reduceY; + const uint8_t pxLen; + const uint8_t alphaOffs; + const IndexFunction indexFn; +}; + +// immutable map detailing where we can skip computation +struct QuadTree { + QuadTree(const SampleParam *param, uint32_t &totalComputation) : root(this->scanImg(0, 0, param->imgWidth, param->imgHeight, param, totalComputation)) + {} + ~QuadTree() { + Node::wither(root); + } + + struct Node; + using Branch = std::variant; + struct Node { + const std::array branches; + Node(Branch nw, Branch ne, Branch sw, Branch se) : branches {nw, ne, sw, se} {}; + ~Node() { + for ( Branch branch : this->branches ) { + wither(branch); + } + } + static void wither(Branch c) { + if (holds_alternative(c)) { + delete get(c); + } + } + }; + + static bool brancheq(Branch a, Branch b) { + return holds_alternative(a) && holds_alternative(b) && get(a) == get(b); + } + + const Branch root; +private: + // TODO: cleverer scan to partition top-down? + static Branch scanImg(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const SampleParam *param, uint32_t &totalComputation) { + if (w <= param->granularity || h <= param->granularity) { + // extend sensitivity to minimum safe distance. a safe leaf is a safe leaf, no need to peek. + int32_t ix = x - param->searchRadius + param->offsX, tx = x + w + param->searchRadius + param->offsX; + int32_t iy = y - param->searchRadius + param->offsY, ty = y + h + param->searchRadius + param->offsY; + Leaf curShade = param->thresh(param->sample({ix, iy})); + + for (auto jx = ix; jx < tx; jx++) { + for (auto jy = iy; jy < ty; jy++) { + if (param->thresh(param->sample({jx, jy})) != curShade) { + totalComputation += w / param->reduceX * h / param->reduceY; + return FIGUREITOUT; + } + } + } + + return curShade; + } + + uint16_t hw = w / 2, hh = h / 2; + Branch iNW = scanImg(x, y, hw, hh, param, totalComputation); + Branch iNE = scanImg(x + hw, y, hw, hh, param, totalComputation); + Branch iSW = scanImg(x, y + hh, hw, hh, param, totalComputation); + Branch iSE = scanImg(x + hw, y + hh, hw, hh, param, totalComputation); + + if (brancheq(iNW, iNE) && brancheq(iNE, iSW) && brancheq(iSW, iSE)) { + return get(iNW); + } + return new Node(iNW, iNE, iSW, iSE); + } +}; + +// traverse a QuadTree at one row of a leaf at a time +class RasterCursor { +public: + RasterCursor(const QuadTree *tree, uint16_t imgWidth, uint16_t imgHeight, uint16_t reduceX = 1, uint16_t reduceY = 1, uint16_t offsX = 0, uint16_t offsY = 0) + : tree(tree) + , imgWidth(imgWidth) + , imgHeight(imgHeight) + , reduceX(reduceX) + , reduceY(reduceY) + , offsX(offsX) + , curX(offsX) + , curY(offsY) + { + this->curShade = this->reorient(); + } + RasterCursor(const QuadTree *tree, const SampleParam *param) + : RasterCursor(tree, param->imgWidth, param->imgHeight, param->reduceX, param->reduceY, param->offsX, param->offsY) + {} + + bool getContiguousRun(Leaf &srcShade, uint16_t &srcY, uint16_t &srcX, uint16_t &dstW) { + srcX = this->curX; + srcY = this->curY; + dstW = 0; + srcShade = this->curShade = this->reorient(); + + while (this->curX < this->imgWidth && QuadTree::brancheq(srcShade, this->curShade)) { + auto blkSize = this->imgWidth >> this->depth; + this->curX += blkSize; + dstW += blkSize / this->reduceX; + this->curShade = this->reorient(); + } + + if (this->curX >= this->imgWidth) { + this->curX = this->offsX; + this->curY += this->reduceY; + } + + return this->curY < this->imgHeight; + } +private: + Leaf reorient() { + this->depth = 0; + this->curBranch = this->tree->root; + auto remX = this->curX, remY = this->curY; + + while (std::holds_alternative(this->curBranch)) { + auto benchX = this->imgWidth >> (this->depth + 1), benchY = this->imgHeight >> (this->depth + 1); + if (benchX < 2 || benchY < 2) { + return this->curShade; // would be nice to make this unrepresentable + } + auto bGtX = remX >= benchX, bGtY = remY >= benchY; + remX -= benchX * bGtX; + remY -= benchY * bGtY; + this->curBranch = std::get(this->curBranch)->branches[bGtX | bGtY << 1]; + this->depth++; + } + + return get(this->curBranch); + } + + const QuadTree *tree; + uint16_t curX, curY, depth; + const uint16_t imgWidth, imgHeight, reduceX, reduceY, offsX; + Leaf curShade; + QuadTree::Branch curBranch; +}; + +float vecDist(Vec2f32 v0, Vec2f32 v1) { + return std::hypot(v0[0] - v1[0], v1[0] - v1[1]); +} +Vec2i vec2ipmul(Vec2i v0, Vec2i v1) { + return {v0[0] * v1[0], v0[1] * v1[1]}; +} + +using math::pi_f32; + +void paintMap( + const SampleParam *param, + float *dstImg, + uint8_t dstPxLen, + uint8_t dstAlphaOffs, + bool distanceAA, + bool euclidean, + const float *tangentDiffusion, + bool *valveQuirks +) { + uint32_t totalComputation = 0; + + const QuadTree tree(param, totalComputation); + RasterCursor cursor(&tree, param); + + uint32_t iOut = dstAlphaOffs; + Leaf srcShade = FIGUREITOUT; + uint16_t srcY = 0, srcX = 0, dstW = 0; + + float fRadius = static_cast(param->searchRadius); + uint16_t dstWidth = param->imgWidth / param->reduceX; + uint16_t dstHeight = param->imgHeight / param->reduceY; + + uint32_t iGradW = 0; + std::vector gradients = tangentDiffusion ? std::vector(totalComputation, -pi_f32 - 0.05f) : std::vector(0); + float lastAng = 0; + float hypot1 = 1; + + bool keepPainting = true; + do { + keepPainting = cursor.getContiguousRun(srcShade, srcY, srcX, dstW); + float fillDistance = 1.0f; + switch (srcShade) { + case OUT: + fillDistance = 0.0f; + case IN: + for (uint16_t i = 0; i < dstW; i++, iOut += dstPxLen) { + dstImg[iOut] = fillDistance; + } + break; + case FIGUREITOUT: + default: + // oh no we actually have to do work + for (uint16_t i = 0; i < dstW; i++, iGradW++, iOut += dstPxLen) { + int32_t curX = srcX + i * param->reduceX; + float alphaRef = param->sample({curX, srcY}); + Leaf stateRef = param->thresh(alphaRef); + float nearest = std::numeric_limits::max(); + for (int32_t sX = curX - param->searchRadius, tX = curX + param->searchRadius; sX < tX; sX++) { + for (int32_t sY = srcY - param->searchRadius, tY = srcY + param->searchRadius; sY < tY; sY++) { + float alphaSmp = param->sample({sX, sY}); + Leaf stateSmp = param->thresh(alphaSmp); + if (stateSmp != stateRef) { + float dist = std::hypot(sX - curX, sY - srcY); + + if (tangentDiffusion || distanceAA || euclidean) { + lastAng = atan2(sX - curX, sY - srcY); + } + if (distanceAA || euclidean) { + hypot1 = 1 / cos(fabs(fmod(lastAng + pi_f32 * 2.25f, pi_f32 / 2.0f) - pi_f32 / 4.0f)); + } + if (distanceAA) { + dist += hypot1 * (1 - fabs(alphaSmp - alphaRef)); + } + if (euclidean && dist > fRadius + hypot1) { + continue; + } + nearest = fmin(nearest, dist); + } + } + } + + nearest = fmin(0.5f, nearest * 0.5f / fRadius); + if (stateRef == OUT) { + nearest = -nearest; + } + + dstImg[iOut] = 0.5 + nearest; + if (tangentDiffusion) { + gradients[iGradW] = lastAng; + } + } + } + } while (keepPainting); + + if (tangentDiffusion) { + // attempt to diffuse quantization error tangent to the gradient of the + // distance map. you could imagine some approach that involves grouping + // greyscale pixels into distinct contours, and playing ring around the + // rosy for each group, but there's no practical way to avoid littering + // residual bits of error into pixels you've already quantized, ruining + // the whole point. the usual suspects like floyd-steinberg use kernels + // shaped to only write *ahead* of a conventional raster scan. here, we + // do the same by determining a shape, bounded by the following "mask": + // + // ⎡ 0 0 0 ⎤ + // ⎢ 0 0 1 ⎥ + // ⎣ 1 1 1 ⎦, + // + // mapping adjacent pixels to the unit circle's bounding box like this: + // + // ⎡ NW:(-1, 1) N:(0, 1) NE:(1, 1) ⎤ + // ⎢ W:(-1, 0) E:(1, 0) ⎥ + // ⎣ SW:(-1, -1) S:(0, -1) SE:(1, -1) ⎦, + // + // choose the two points nearest (x, y) on the unit circle at θ + π / 2 + // (i.e. the right-hand normal of the gradient direction θ). distribute + // the quantization error E between the two, weighted by the opposite's + // distance over the sum of both distances (it feels as though a better + // weighting should exist but that's what i get for dropping out), then + // fold the whole thing over to only entries ahead of us in scan order: + // + // ⎡ ⎤ + // k = ⎢ E+W ⎥ + // ⎣ SW+NE S+N SE+NW ⎦, + // + // yielding our per-destination-pixel kernel k. a neat property of this + // is that we can continue ignoring pixels we previously ignored thanks + // to the quadtree (and thus, never set any angle for): we're diffusing + // error tangent to the gradient, and that way lie only pixels where we + // also already set an angle. we can index angles independently, which, + // depending on the contents of the source image, might require a small + // fraction of the space compared to the distance map (or just as much) + static constexpr std::array hardSigns{1, 1, 0, -1, -1, -1, 0, 1}; + // of course, in our case, y grows downward, so we really want is E..NW + // which, conveniently enough, is just 0..3, so no aux lookup table. if + // we wanted SW..E, we'd index into {0, 5, 6, 7}. + static constexpr float eighth = pi_f32 / 4.0f; + + RasterCursor diffCursor(&tree, dstWidth, dstHeight); + uint32_t diffOut = dstAlphaOffs; + Leaf diffShade = FIGUREITOUT; + const uint32_t dstBufLen = dstWidth * dstHeight * dstPxLen; + uint16_t diffY = 0, diffX = 0, diffW = 0; + uint32_t iGradR = 0; + + bool keepDiffusing = true; + do { + keepDiffusing = diffCursor.getContiguousRun(diffShade, diffY, diffX, diffW); + if (diffShade != FIGUREITOUT) { + continue; + } + bool rightEdge = diffX + diffW >= dstWidth; + + for (uint16_t i = 0; i < diffW; i++, iGradR++, diffOut += dstPxLen) { + float theta = gradients[iGradR]; + if (theta < -pi_f32 - 0.025f) { + continue; + } + float thetaNormal = fmod(theta + 2.5f * pi_f32, 2.0f * pi_f32); + int8_t nEighths = (thetaNormal - fmod(thetaNormal, eighth)) / eighth; + float quantErr = fmod(dstImg[diffOut], *tangentDiffusion); + dstImg[diffOut] -= quantErr; + bool atRight = rightEdge && i >= diffW - 1; + bool bottomEdge = diffY >= dstHeight - 1; + if (atRight && bottomEdge) { + break; + } + Vec2f32 normal{cos(thetaNormal), sin(thetaNormal)}; + // distance calculation uses the whole circle + float dist0 = vecDist(normal, {hardSigns[(nEighths + 0) % 8], hardSigns[(nEighths + 2) % 8]}); + float dist1 = vecDist(normal, {hardSigns[(nEighths + 1) % 8], hardSigns[(nEighths + 3) % 8]}); + // offset selection uses the part of the circle that is yet to be scanned + Vec2i offs0{hardSigns[(nEighths + 0) % 4], hardSigns[(nEighths + 2) % 4]}; + Vec2i offs1{hardSigns[(nEighths + 1) % 4], hardSigns[(nEighths + 3) % 4]}; + Vec2i pos{diffX + i, diffY}; + if (bottomEdge) { + offs0 = vec2ipmul(offs0, {1, 0}); + offs1 = vec2ipmul(offs1, {1, 0}); + } + dstImg[param->indexFn(pos + offs0, dstWidth, dstHeight, dstPxLen, dstAlphaOffs)] += quantErr * dist1 / (dist0 + dist1); + dstImg[param->indexFn(pos + offs1, dstWidth, dstHeight, dstPxLen, dstAlphaOffs)] += quantErr * dist0 / (dist0 + dist1); + } + } while (keepDiffusing); + } + + if (valveQuirks) { + *valveQuirks = false; + + auto blackOutAndWarn = [&](auto oi) { + float& px = dstImg[oi * dstPxLen + dstAlphaOffs]; + if (fabs(px) > 0.000001f) { + *valveQuirks = true; + } + px = 0.0f; + }; + + for (auto x = 0; x < dstWidth; x++) { + blackOutAndWarn(x); + blackOutAndWarn(x + (dstHeight - 1) * dstWidth); + } + for (auto y = 0; y < dstHeight; y++) { + blackOutAndWarn(y * dstWidth); + blackOutAndWarn(y * dstWidth + dstHeight - 1); + } + } +} + +bool operator!(ImageConversion::DistanceFlags flags) { + return flags == ImageConversion::DistanceFlags::NONE; +}; + +bool isSingleChannel(ImageFormat format) { + using namespace ImageFormatDetails; + return red(format) == bpp(format) && bpp(format); +} + +} // namespace +} // namespace vtfpp::detail::DistanceMapping + +[[nodiscard]] std::vector ImageConversion::alphaToDistance(std::span imageData, ImageFormat inFormat, ImageFormat outFormat, uint16_t width, uint16_t height, uint16_t reduceX, uint16_t reduceY, bool srgb, float distanceSpread, float alphaThreshold, ImageConversion::DistanceFlags flags, ImageConversion::DistanceDither dither, ImageConversion::ResizeFilter filter, ImageConversion::ResizeEdge edge, bool *valveQuirks) { + using namespace vtfpp::detail::DistanceMapping; + using namespace ImageFormatDetails; + + auto mkFormat = [](ImageFormat format) { + return isSingleChannel(format) ? + std::make_tuple(ImageFormat::R32F, uint8_t{1}, uint8_t{0}) + : (decompressedAlpha(format) || alpha(format)) ? + std::make_tuple(ImageFormat::RGBA32323232F, uint8_t{4}, uint8_t{3}) + : + std::make_tuple(ImageFormat::EMPTY, uint8_t{0}, uint8_t{0}); + }; + auto [intermediateRead, srcPxLen, srcAlphaOffs] = mkFormat(inFormat); + auto [intermediateWrite, dstPxLen, dstAlphaOffs] = mkFormat(outFormat); + + if ( + intermediateRead == ImageFormat::EMPTY + || intermediateWrite == ImageFormat::EMPTY + || !math::isPowerOf2(reduceX) + || !math::isPowerOf2(reduceY) + || ftoabsu16(distanceSpread * reduceX) < 1 + || ftoabsu16(distanceSpread * reduceY) < 1 + || std::clamp(alphaThreshold, 0.0f, 1.0f) != alphaThreshold + || edge == ImageConversion::ResizeEdge::ZERO + ) { + return {}; + } + + bool inputNeedsConversion = intermediateRead != inFormat; + std::vector i_love_raii = inputNeedsConversion ? convertImageDataToFormat(imageData, inFormat, intermediateRead, width, height) : std::vector(0); + std::span srcImg = inputNeedsConversion ? i_love_raii : imageData; + + SampleParam param(reinterpret_cast(srcImg.data()), width, height, reduceX, reduceY, srcPxLen, srcAlphaOffs, distanceSpread, !!(flags & DistanceFlags::SAMPLECENTERED), alphaThreshold, edge); + + uint16_t dstWidth = width / reduceX, dstHeight = height / reduceY; + + std::vector dstImg + = (!isSingleChannel(inFormat) && !isSingleChannel(outFormat)) ? + convertImageDataToFormat( + resizeImageData(imageData, inFormat, width, dstWidth, height, dstHeight, srgb, true, filter, edge), + inFormat, intermediateWrite, dstWidth, dstHeight + ) + : + std::vector(sizeof(float) * dstPxLen * dstWidth * dstHeight, std::byte{0x00}); + + float quantum = 1.0f / static_cast(1 << decompressedAlpha(outFormat)); + bool doGradientDither = dither == DistanceDither::GRADIENT_TANGENT && !decimal(outFormat); + paintMap( + ¶m, + reinterpret_cast(dstImg.data()), + dstPxLen, + dstAlphaOffs, + !!(flags & DistanceFlags::DISTANCEAA), + !!(flags & DistanceFlags::EUCLIDEAN), + doGradientDither ? &quantum : nullptr, + valveQuirks + ); + + return (outFormat == intermediateWrite) ? dstImg : convertImageDataToFormat(dstImg, intermediateWrite, outFormat, dstWidth, dstHeight); +} From 0c34e1e2cba6ee2cfefb4f698bb62b4b87e3c40d Mon Sep 17 00:00:00 2001 From: yuuko Date: Sun, 17 May 2026 13:44:51 -0700 Subject: [PATCH 2/8] vtfpp: ImageConversion::alphaToDistance -> DistanceMapping::alphaToDistance --- include/vtfpp/DistanceMapping.h | 57 ++++ include/vtfpp/ImageConversion.h | 46 --- include/vtfpp/vtfpp.h | 1 + src/vtfpp/DistanceMapping.cpp | 528 ++++++++++++++++++++++++++++++++ src/vtfpp/ImageConversion.cpp | 518 ------------------------------- src/vtfpp/_vtfpp.cmake | 2 + 6 files changed, 588 insertions(+), 564 deletions(-) create mode 100644 include/vtfpp/DistanceMapping.h create mode 100644 src/vtfpp/DistanceMapping.cpp diff --git a/include/vtfpp/DistanceMapping.h b/include/vtfpp/DistanceMapping.h new file mode 100644 index 00000000..31227f6e --- /dev/null +++ b/include/vtfpp/DistanceMapping.h @@ -0,0 +1,57 @@ +#pragma once + +#include + +#include + +#include + +namespace vtfpp::DistanceMapping { + +enum class Dither { + NONE = 0, ///< No dithering. + GRADIENT_TANGENT, ///< Experimental dithering approach that diffuses quantization error perpendicular to the distance gradient at any destination pixel. Slow and requires up to double the memory. + // anything generic that doesn't depend on the geometry of the distance field (floyd-steinberg etc) would belong somewhere more general. +}; + +enum class Flags : uint32_t { + NONE = 0, + DISTANCEAA = 1 << 0, ///< Experimental; interpret the alpha channel as antialiased. Can result in a more precise distance map, but produces nonsense if large gradients are present. + EUCLIDEAN = 1 << 1, ///< The distance-mapping algorithm is a brute-force scan of a *square* area. If this is enabled, only accept distance hits in a circular area. + SAMPLECENTERED = 1 << 2, ///< Search from the center of pixels (in destination coordinate space) rather than in their north-west corners. Can mitigate a perceived southeast shift at extreme reductions. +}; + +SOURCEPP_BITFLAGS_ENUM(Flags) + +/// In one operation, convert an image's alpha channel, or, for single-channel formats, its only channel, to a VTEX-style distance map, and downscale other channels, if present in the output, according to the given resize parameters. +/// @return Empty if: +/// * \p inFormat or \p outFormat do not satisy their documented predicates, +/// * \p reduceX or \p reduceY are not powers of two, +/// * \p distanceSpread would result in a search radius of zero with either \p reduceX or \p reduceY, +/// * \p alphaThreshold is out of range, or +/// * \p edge is ResizeEdge::ZERO. +/// +/// Otherwise, an image at \p width / \p reduceX by \p height / \p reduceY in \p outFormat: +/// * if \p outFormat is single-channel, R contains the distance map. +/// * if \p outFormat has alpha, A contains the distance map, while R/G/B contain: +/// * if \p inFormat had R/G/B, the scaled R/G/B of the input image, otherwise +/// * 0 in all other channels. +[[nodiscard]] std::vector alphaToDistance( + std::span imageData, + vtfpp::ImageFormat inFormat, ///< Any format that is either single-channel, or has an alpha channel. + vtfpp::ImageFormat outFormat, ///< The same requirements as inFormat; channel count does not need to correspond to inFormat. + uint16_t width, + uint16_t height, + uint16_t reduceX, ///< Power-of-two horizontal reduction factor. + uint16_t reduceY, ///< Power-of-two vertical reduction factor. + bool srgb, ///< Used in the case of RGBA->RGBA. premultipliedAlpha is omitted. + float distanceSpread = 1.0f, ///< Scale factor beyond \p reduceX and \p reduceY, of search radius for distance hits. + float alphaThreshold = 0.04f, ///< Threshold below which alpha values are considered zero. + Flags flags = Flags::NONE, ///< Additional behaviors that deviate from VTEX but may be useful; see DistanceFlags. + Dither dither = Dither::NONE, ///< Internally, distance maps are always computed in floating point. When set to a value other than NONE, and the output format is of integral type, dithering is applied prior to quantization. Depending on the application and contents, this can improve or worsen the quality of the distance map. + ImageConversion::ResizeFilter filter = ImageConversion::ResizeFilter::NICE, ///< Default value mimics VTEX. Does not affect the alpha channel; distance mapping is a distinct sampling operation from source to destination space. Entirely unused if input is single-channel. + ImageConversion::ResizeEdge edge = ImageConversion::ResizeEdge::CLAMP, ///< Dictates the sampling policy of the distance function regardless of whether there are non-alpha channels to be resized. + bool *valveQuirks = nullptr ///< When non-null, mimic VTEX's policy of blanking out any edge pixels in the distance map to 0 (i.e. infinite distance), and report back whether this resulted in any change (such that a command-line tool emulating VTEX would want to report a warning). +); + +} // namespace DistanceMapping diff --git a/include/vtfpp/ImageConversion.h b/include/vtfpp/ImageConversion.h index cb520773..430e6df2 100644 --- a/include/vtfpp/ImageConversion.h +++ b/include/vtfpp/ImageConversion.h @@ -139,50 +139,4 @@ void invertGreenChannelForImageData(std::span imageData, ImageFormat /// Perform Hable tonemapping on the given image data void hableTonemapImageData(std::span imageData, ImageFormat format, uint16_t width, uint16_t height); -enum class DistanceDither { - NONE = 0, ///< No dithering. - GRADIENT_TANGENT, ///< Experimental dithering approach that diffuses quantization error perpendicular to the distance gradient at any destination pixel. Slow and requires up to double the memory. - // anything generic that doesn't depend on the geometry of the distance field (floyd-steinberg etc) would belong somewhere more general. -}; - -enum class DistanceFlags : uint32_t { - NONE = 0, - DISTANCEAA = 1 << 0, ///< Experimental; interpret the alpha channel as antialiased. Can result in a more precise distance map, but produces nonsense if large gradients are present. - EUCLIDEAN = 1 << 1, ///< The distance-mapping algorithm is a brute-force scan of a *square* area. If this is enabled, only accept distance hits in a circular area. - SAMPLECENTERED = 1 << 2, ///< Search from the center of pixels (in destination coordinate space) rather than in their north-west corners. Can mitigate a perceived southeast shift at extreme reductions. -}; - -SOURCEPP_BITFLAGS_ENUM(DistanceFlags) - -/// In one operation, convert an image's alpha channel, or, for single-channel formats, its only channel, to a VTEX-style distance map, and downscale other channels, if present in the output, according to the given resize parameters. -/// @return Empty if: -/// * \p inFormat or \p outFormat do not satisy their documented predicates, -/// * \p reduceX or \p reduceY are not powers of two, -/// * \p distanceSpread would result in a search radius of zero with either \p reduceX or \p reduceY, -/// * \p alphaThreshold is out of range, or -/// * \p edge is ResizeEdge::ZERO. -/// -/// Otherwise, an image at \p width / \p reduceX by \p height / \p reduceY in \p outFormat: -/// * if \p outFormat is single-channel, R contains the distance map. -/// * if \p outFormat has alpha, A contains the distance map, while R/G/B contain: -/// * if \p inFormat had R/G/B, the scaled R/G/B of the input image, otherwise -/// * 0 in all other channels. -[[nodiscard]] std::vector alphaToDistance( - std::span imageData, - ImageFormat inFormat, ///< Any format that is either single-channel, or has an alpha channel. - ImageFormat outFormat, ///< The same requirements as inFormat; channel count does not need to correspond to inFormat. - uint16_t width, - uint16_t height, - uint16_t reduceX, ///< Power-of-two horizontal reduction factor. - uint16_t reduceY, ///< Power-of-two vertical reduction factor. - bool srgb, ///< Used in the case of RGBA->RGBA. premultipliedAlpha is omitted. - float distanceSpread = 1.0f, ///< Scale factor beyond \p reduceX and \p reduceY, of search radius for distance hits. - float alphaThreshold = 0.04f, ///< Threshold below which alpha values are considered zero. - DistanceFlags flags = DistanceFlags::NONE, ///< Additional behaviors that deviate from VTEX but may be useful; see DistanceFlags. - DistanceDither dither = DistanceDither::NONE, ///< Internally, distance maps are always computed in floating point. When set to a value other than NONE, and the output format is of integral type, dithering is applied prior to quantization. Depending on the application and contents, this can improve or worsen the quality of the distance map. - ResizeFilter filter = ResizeFilter::NICE, ///< Default value mimics VTEX. Does not affect the alpha channel; distance mapping is a distinct sampling operation from source to destination space. Entirely unused if input is single-channel. - ResizeEdge edge = ResizeEdge::CLAMP, ///< Dictates the sampling policy of the distance function regardless of whether there are non-alpha channels to be resized. - bool *valveQuirks = nullptr ///< When non-null, mimic VTEX's policy of blanking out any edge pixels in the distance map to 0 (i.e. infinite distance), and report back whether this resulted in any change (such that a command-line tool emulating VTEX would want to report a warning). -); - } // namespace vtfpp::ImageConversion diff --git a/include/vtfpp/vtfpp.h b/include/vtfpp/vtfpp.h index 6e3c7b09..2731ba49 100644 --- a/include/vtfpp/vtfpp.h +++ b/include/vtfpp/vtfpp.h @@ -5,6 +5,7 @@ * include it the same way as any of the other SourcePP libraries. */ +#include "DistanceMapping.h" #include "HOT.h" #include "ImageConversion.h" #include "ImageFormats.h" diff --git a/src/vtfpp/DistanceMapping.cpp b/src/vtfpp/DistanceMapping.cpp new file mode 100644 index 00000000..d720d69b --- /dev/null +++ b/src/vtfpp/DistanceMapping.cpp @@ -0,0 +1,528 @@ +#include +#include + +#include + +#include +#include + +using namespace sourcepp; +using namespace vtfpp; + +namespace vtfpp::DistanceMapping::detail { +namespace { + +using namespace ImageConversion; +using math::Vec2i; +using math::Vec2f32; + +enum Leaf : uint8_t { + IN, // leaf is opaque + OUT, // leaf is transparent + FIGUREITOUT, // leaf is mixed but small so you should brute force scan. +}; + +uint16_t i32tou16sat(int32_t i, int32_t cap = UINT16_MAX) { + return static_cast(std::clamp(i, 0, cap)); +} + +uint16_t ftoabsu16(float f) { + return i32tou16sat(static_cast(fabs(f))); +} + +using IndexFunction = std::function; + +int32_t indexClamped(Vec2i v, uint16_t w, uint16_t h, uint8_t pxLen, uint8_t alphaOffs) { + int32_t rX = i32tou16sat(v[0], w - 1); + int32_t rY = i32tou16sat(v[1], h - 1); + return (rY * w + rX) * pxLen + alphaOffs; +} + +int32_t indexReflected(Vec2i v, uint16_t w, uint16_t h, uint8_t pxLen, uint8_t alphaOffs) { + int32_t rX = v[0] >= w ? w - v[0] % w : v[0]; + int32_t rY = v[1] >= h ? h - v[1] % h : v[1]; + return (rY * w + rX) * pxLen + alphaOffs; +} + +int32_t indexWrapped(Vec2i v, uint16_t w, uint16_t h, uint8_t pxLen, uint8_t alphaOffs) { + int32_t rX = v[0] % w; + int32_t rY = v[1] % h; + return (rY * w + rX) * pxLen + alphaOffs; +} + +IndexFunction indexBy(ImageConversion::ResizeEdge edge) +{ + switch (edge) { + default: + break; + case ImageConversion::ResizeEdge::REFLECT: + return indexReflected; + case ImageConversion::ResizeEdge::WRAP: + return indexWrapped; + } + return indexClamped; +} + +// big ugly immutable record of everything pertaining to how we read alpha from an image +class SampleParam { +public: + SampleParam( + const float *srcImg, + uint16_t inWidth, + uint16_t inHeight, + uint16_t reduceX, + uint16_t reduceY, + uint8_t pxLen, + uint8_t alphaOffs, + float distanceSpread, + bool sampleCentered, + float alphaThreshold, + ImageConversion::ResizeEdge edge + ) + : srcImg(srcImg) + , pxLen(pxLen) + , alphaOffs(alphaOffs) + , imgWidth(inWidth) + , imgHeight(inHeight) + , reduceX(reduceX) + , reduceY(reduceY) + , searchRadius(ftoabsu16(2.0f * std::max(reduceX, reduceY) * distanceSpread)) + // the second power of two after search radius + , granularity(uint16_t{4} << ftoabsu16(ceil(log2(static_cast(this->searchRadius))))) + , offsX(sampleCentered ? reduceX / 2 : 0) + , offsY(sampleCentered ? reduceY / 2 : 0) + , alphaThreshold(alphaThreshold) + , indexFn(indexBy(edge)) + {} + + float sample(Vec2i v) const { + return this->srcImg[this->indexFn(v, this->imgWidth, this->imgHeight, this->pxLen, this->alphaOffs)]; + } + + Leaf thresh(float alpha) const { + return alpha >= this->alphaThreshold ? IN : OUT; + } + + const float *const srcImg; + const float alphaThreshold; + const uint16_t imgHeight; + const uint16_t imgWidth; + const uint16_t searchRadius; + const uint16_t granularity; + const uint16_t offsX, offsY; + const uint16_t reduceX, reduceY; + const uint8_t pxLen; + const uint8_t alphaOffs; + const IndexFunction indexFn; +}; + +// immutable map detailing where we can skip computation +struct QuadTree { + QuadTree(const SampleParam *param, uint32_t &totalComputation) : root(this->scanImg(0, 0, param->imgWidth, param->imgHeight, param, totalComputation)) + {} + ~QuadTree() { + Node::wither(root); + } + + struct Node; + using Branch = std::variant; + struct Node { + const std::array branches; + Node(Branch nw, Branch ne, Branch sw, Branch se) : branches {nw, ne, sw, se} {}; + ~Node() { + for ( Branch branch : this->branches ) { + wither(branch); + } + } + static void wither(Branch c) { + if (holds_alternative(c)) { + delete get(c); + } + } + }; + + static bool brancheq(Branch a, Branch b) { + return holds_alternative(a) && holds_alternative(b) && get(a) == get(b); + } + + const Branch root; +private: + // TODO: cleverer scan to partition top-down? + static Branch scanImg(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const SampleParam *param, uint32_t &totalComputation) { + if (w <= param->granularity || h <= param->granularity) { + // extend sensitivity to minimum safe distance. a safe leaf is a safe leaf, no need to peek. + int32_t ix = x - param->searchRadius + param->offsX, tx = x + w + param->searchRadius + param->offsX; + int32_t iy = y - param->searchRadius + param->offsY, ty = y + h + param->searchRadius + param->offsY; + Leaf curShade = param->thresh(param->sample({ix, iy})); + + for (auto jx = ix; jx < tx; jx++) { + for (auto jy = iy; jy < ty; jy++) { + if (param->thresh(param->sample({jx, jy})) != curShade) { + totalComputation += w / param->reduceX * h / param->reduceY; + return FIGUREITOUT; + } + } + } + + return curShade; + } + + uint16_t hw = w / 2, hh = h / 2; + Branch iNW = scanImg(x, y, hw, hh, param, totalComputation); + Branch iNE = scanImg(x + hw, y, hw, hh, param, totalComputation); + Branch iSW = scanImg(x, y + hh, hw, hh, param, totalComputation); + Branch iSE = scanImg(x + hw, y + hh, hw, hh, param, totalComputation); + + if (brancheq(iNW, iNE) && brancheq(iNE, iSW) && brancheq(iSW, iSE)) { + return get(iNW); + } + return new Node(iNW, iNE, iSW, iSE); + } +}; + +// traverse a QuadTree at one row of a leaf at a time +class RasterCursor { +public: + RasterCursor(const QuadTree *tree, uint16_t imgWidth, uint16_t imgHeight, uint16_t reduceX = 1, uint16_t reduceY = 1, uint16_t offsX = 0, uint16_t offsY = 0) + : tree(tree) + , imgWidth(imgWidth) + , imgHeight(imgHeight) + , reduceX(reduceX) + , reduceY(reduceY) + , offsX(offsX) + , curX(offsX) + , curY(offsY) + { + this->curShade = this->reorient(); + } + RasterCursor(const QuadTree *tree, const SampleParam *param) + : RasterCursor(tree, param->imgWidth, param->imgHeight, param->reduceX, param->reduceY, param->offsX, param->offsY) + {} + + bool getContiguousRun(Leaf &srcShade, uint16_t &srcY, uint16_t &srcX, uint16_t &dstW) { + srcX = this->curX; + srcY = this->curY; + dstW = 0; + srcShade = this->curShade = this->reorient(); + + while (this->curX < this->imgWidth && QuadTree::brancheq(srcShade, this->curShade)) { + auto blkSize = this->imgWidth >> this->depth; + this->curX += blkSize; + dstW += blkSize / this->reduceX; + this->curShade = this->reorient(); + } + + if (this->curX >= this->imgWidth) { + this->curX = this->offsX; + this->curY += this->reduceY; + } + + return this->curY < this->imgHeight; + } +private: + Leaf reorient() { + this->depth = 0; + this->curBranch = this->tree->root; + auto remX = this->curX, remY = this->curY; + + while (std::holds_alternative(this->curBranch)) { + auto benchX = this->imgWidth >> (this->depth + 1), benchY = this->imgHeight >> (this->depth + 1); + if (benchX < 2 || benchY < 2) { + return this->curShade; // would be nice to make this unrepresentable + } + auto bGtX = remX >= benchX, bGtY = remY >= benchY; + remX -= benchX * bGtX; + remY -= benchY * bGtY; + this->curBranch = std::get(this->curBranch)->branches[bGtX | bGtY << 1]; + this->depth++; + } + + return get(this->curBranch); + } + + const QuadTree *tree; + uint16_t curX, curY, depth; + const uint16_t imgWidth, imgHeight, reduceX, reduceY, offsX; + Leaf curShade; + QuadTree::Branch curBranch; +}; + +float vecDist(Vec2f32 v0, Vec2f32 v1) { + return std::hypot(v0[0] - v1[0], v1[0] - v1[1]); +} +Vec2i vec2ipmul(Vec2i v0, Vec2i v1) { + return {v0[0] * v1[0], v0[1] * v1[1]}; +} + +using math::pi_f32; + +void paintMap( + const SampleParam *param, + float *dstImg, + uint8_t dstPxLen, + uint8_t dstAlphaOffs, + bool distanceAA, + bool euclidean, + const float *tangentDiffusion, + bool *valveQuirks +) { + uint32_t totalComputation = 0; + + const QuadTree tree(param, totalComputation); + RasterCursor cursor(&tree, param); + + uint32_t iOut = dstAlphaOffs; + Leaf srcShade = FIGUREITOUT; + uint16_t srcY = 0, srcX = 0, dstW = 0; + + float fRadius = static_cast(param->searchRadius); + uint16_t dstWidth = param->imgWidth / param->reduceX; + uint16_t dstHeight = param->imgHeight / param->reduceY; + + uint32_t iGradW = 0; + std::vector gradients = tangentDiffusion ? std::vector(totalComputation, -pi_f32 - 0.05f) : std::vector(0); + float lastAng = 0; + float hypot1 = 1; + + bool keepPainting = true; + do { + keepPainting = cursor.getContiguousRun(srcShade, srcY, srcX, dstW); + float fillDistance = 1.0f; + switch (srcShade) { + case OUT: + fillDistance = 0.0f; + case IN: + for (uint16_t i = 0; i < dstW; i++, iOut += dstPxLen) { + dstImg[iOut] = fillDistance; + } + break; + case FIGUREITOUT: + default: + // oh no we actually have to do work + for (uint16_t i = 0; i < dstW; i++, iGradW++, iOut += dstPxLen) { + int32_t curX = srcX + i * param->reduceX; + float alphaRef = param->sample({curX, srcY}); + Leaf stateRef = param->thresh(alphaRef); + float nearest = std::numeric_limits::max(); + for (int32_t sX = curX - param->searchRadius, tX = curX + param->searchRadius; sX < tX; sX++) { + for (int32_t sY = srcY - param->searchRadius, tY = srcY + param->searchRadius; sY < tY; sY++) { + float alphaSmp = param->sample({sX, sY}); + Leaf stateSmp = param->thresh(alphaSmp); + if (stateSmp != stateRef) { + float dist = std::hypot(sX - curX, sY - srcY); + + if (tangentDiffusion || distanceAA || euclidean) { + lastAng = atan2(sX - curX, sY - srcY); + } + if (distanceAA || euclidean) { + hypot1 = 1 / cos(fabs(fmod(lastAng + pi_f32 * 2.25f, pi_f32 / 2.0f) - pi_f32 / 4.0f)); + } + if (distanceAA) { + dist += hypot1 * (1 - fabs(alphaSmp - alphaRef)); + } + if (euclidean && dist > fRadius + hypot1) { + continue; + } + nearest = fmin(nearest, dist); + } + } + } + + nearest = fmin(0.5f, nearest * 0.5f / fRadius); + if (stateRef == OUT) { + nearest = -nearest; + } + + dstImg[iOut] = 0.5 + nearest; + if (tangentDiffusion) { + gradients[iGradW] = lastAng; + } + } + } + } while (keepPainting); + + if (tangentDiffusion) { + // attempt to diffuse quantization error tangent to the gradient of the + // distance map. you could imagine some approach that involves grouping + // greyscale pixels into distinct contours, and playing ring around the + // rosy for each group, but there's no practical way to avoid littering + // residual bits of error into pixels you've already quantized, ruining + // the whole point. the usual suspects like floyd-steinberg use kernels + // shaped to only write *ahead* of a conventional raster scan. here, we + // do the same by determining a shape, bounded by the following "mask": + // + // ⎡ 0 0 0 ⎤ + // ⎢ 0 0 1 ⎥ + // ⎣ 1 1 1 ⎦, + // + // mapping adjacent pixels to the unit circle's bounding box like this: + // + // ⎡ NW:(-1, 1) N:(0, 1) NE:(1, 1) ⎤ + // ⎢ W:(-1, 0) E:(1, 0) ⎥ + // ⎣ SW:(-1, -1) S:(0, -1) SE:(1, -1) ⎦, + // + // choose the two points nearest (x, y) on the unit circle at θ + π / 2 + // (i.e. the right-hand normal of the gradient direction θ). distribute + // the quantization error E between the two, weighted by the opposite's + // distance over the sum of both distances (it feels as though a better + // weighting should exist but that's what i get for dropping out), then + // fold the whole thing over to only entries ahead of us in scan order: + // + // ⎡ ⎤ + // k = ⎢ E+W ⎥ + // ⎣ SW+NE S+N SE+NW ⎦, + // + // yielding our per-destination-pixel kernel k. a neat property of this + // is that we can continue ignoring pixels we previously ignored thanks + // to the quadtree (and thus, never set any angle for): we're diffusing + // error tangent to the gradient, and that way lie only pixels where we + // also already set an angle. we can index angles independently, which, + // depending on the contents of the source image, might require a small + // fraction of the space compared to the distance map (or just as much) + static constexpr std::array hardSigns{1, 1, 0, -1, -1, -1, 0, 1}; + // of course, in our case, y grows downward, so we really want is E..NW + // which, conveniently enough, is just 0..3, so no aux lookup table. if + // we wanted SW..E, we'd index into {0, 5, 6, 7}. + static constexpr float eighth = pi_f32 / 4.0f; + + RasterCursor diffCursor(&tree, dstWidth, dstHeight); + uint32_t diffOut = dstAlphaOffs; + Leaf diffShade = FIGUREITOUT; + const uint32_t dstBufLen = dstWidth * dstHeight * dstPxLen; + uint16_t diffY = 0, diffX = 0, diffW = 0; + uint32_t iGradR = 0; + + bool keepDiffusing = true; + do { + keepDiffusing = diffCursor.getContiguousRun(diffShade, diffY, diffX, diffW); + if (diffShade != FIGUREITOUT) { + continue; + } + bool rightEdge = diffX + diffW >= dstWidth; + + for (uint16_t i = 0; i < diffW; i++, iGradR++, diffOut += dstPxLen) { + float theta = gradients[iGradR]; + if (theta < -pi_f32 - 0.025f) { + continue; + } + float thetaNormal = fmod(theta + 2.5f * pi_f32, 2.0f * pi_f32); + int8_t nEighths = (thetaNormal - fmod(thetaNormal, eighth)) / eighth; + float quantErr = fmod(dstImg[diffOut], *tangentDiffusion); + dstImg[diffOut] -= quantErr; + bool atRight = rightEdge && i >= diffW - 1; + bool bottomEdge = diffY >= dstHeight - 1; + if (atRight && bottomEdge) { + break; + } + Vec2f32 normal{cos(thetaNormal), sin(thetaNormal)}; + // distance calculation uses the whole circle + float dist0 = vecDist(normal, {hardSigns[(nEighths + 0) % 8], hardSigns[(nEighths + 2) % 8]}); + float dist1 = vecDist(normal, {hardSigns[(nEighths + 1) % 8], hardSigns[(nEighths + 3) % 8]}); + // offset selection uses the part of the circle that is yet to be scanned + Vec2i offs0{hardSigns[(nEighths + 0) % 4], hardSigns[(nEighths + 2) % 4]}; + Vec2i offs1{hardSigns[(nEighths + 1) % 4], hardSigns[(nEighths + 3) % 4]}; + Vec2i pos{diffX + i, diffY}; + if (bottomEdge) { + offs0 = vec2ipmul(offs0, {1, 0}); + offs1 = vec2ipmul(offs1, {1, 0}); + } + dstImg[param->indexFn(pos + offs0, dstWidth, dstHeight, dstPxLen, dstAlphaOffs)] += quantErr * dist1 / (dist0 + dist1); + dstImg[param->indexFn(pos + offs1, dstWidth, dstHeight, dstPxLen, dstAlphaOffs)] += quantErr * dist0 / (dist0 + dist1); + } + } while (keepDiffusing); + } + + if (valveQuirks) { + *valveQuirks = false; + + auto blackOutAndWarn = [&](auto oi) { + float& px = dstImg[oi * dstPxLen + dstAlphaOffs]; + if (fabs(px) > 0.000001f) { + *valveQuirks = true; + } + px = 0.0f; + }; + + for (auto x = 0; x < dstWidth; x++) { + blackOutAndWarn(x); + blackOutAndWarn(x + (dstHeight - 1) * dstWidth); + } + for (auto y = 0; y < dstHeight; y++) { + blackOutAndWarn(y * dstWidth); + blackOutAndWarn(y * dstWidth + dstHeight - 1); + } + } +} + +bool operator!(Flags flags) { + return flags == Flags::NONE; +}; + +bool isSingleChannel(ImageFormat format) { + using namespace ImageFormatDetails; + return red(format) == bpp(format) && bpp(format); +} + +} // namespace +} // namespace vtfpp::DistanceMapping::detail + +[[nodiscard]] std::vector DistanceMapping::alphaToDistance(std::span imageData, ImageFormat inFormat, ImageFormat outFormat, uint16_t width, uint16_t height, uint16_t reduceX, uint16_t reduceY, bool srgb, float distanceSpread, float alphaThreshold, DistanceMapping::Flags flags, DistanceMapping::Dither dither, ImageConversion::ResizeFilter filter, ImageConversion::ResizeEdge edge, bool *valveQuirks) { + using namespace vtfpp::DistanceMapping::detail; + using namespace ImageFormatDetails; + + auto mkFormat = [](ImageFormat format) { + return isSingleChannel(format) ? + std::make_tuple(ImageFormat::R32F, uint8_t{1}, uint8_t{0}) + : (decompressedAlpha(format) || alpha(format)) ? + std::make_tuple(ImageFormat::RGBA32323232F, uint8_t{4}, uint8_t{3}) + : + std::make_tuple(ImageFormat::EMPTY, uint8_t{0}, uint8_t{0}); + }; + auto [intermediateRead, srcPxLen, srcAlphaOffs] = mkFormat(inFormat); + auto [intermediateWrite, dstPxLen, dstAlphaOffs] = mkFormat(outFormat); + + if ( + intermediateRead == ImageFormat::EMPTY + || intermediateWrite == ImageFormat::EMPTY + || !math::isPowerOf2(reduceX) + || !math::isPowerOf2(reduceY) + || ftoabsu16(distanceSpread * reduceX) < 1 + || ftoabsu16(distanceSpread * reduceY) < 1 + || std::clamp(alphaThreshold, 0.0f, 1.0f) != alphaThreshold + || edge == ImageConversion::ResizeEdge::ZERO + ) { + return {}; + } + + bool inputNeedsConversion = intermediateRead != inFormat; + std::vector i_love_raii = inputNeedsConversion ? convertImageDataToFormat(imageData, inFormat, intermediateRead, width, height) : std::vector(0); + std::span srcImg = inputNeedsConversion ? i_love_raii : imageData; + + SampleParam param(reinterpret_cast(srcImg.data()), width, height, reduceX, reduceY, srcPxLen, srcAlphaOffs, distanceSpread, !!(flags & Flags::SAMPLECENTERED), alphaThreshold, edge); + + uint16_t dstWidth = width / reduceX, dstHeight = height / reduceY; + + std::vector dstImg + = (!isSingleChannel(inFormat) && !isSingleChannel(outFormat)) ? + convertImageDataToFormat( + resizeImageData(imageData, inFormat, width, dstWidth, height, dstHeight, srgb, true, filter, edge), + inFormat, intermediateWrite, dstWidth, dstHeight + ) + : + std::vector(sizeof(float) * dstPxLen * dstWidth * dstHeight, std::byte{0x00}); + + float quantum = 1.0f / static_cast(1 << decompressedAlpha(outFormat)); + bool doGradientDither = dither == Dither::GRADIENT_TANGENT && !decimal(outFormat); + paintMap( + ¶m, + reinterpret_cast(dstImg.data()), + dstPxLen, + dstAlphaOffs, + !!(flags & Flags::DISTANCEAA), + !!(flags & Flags::EUCLIDEAN), + doGradientDither ? &quantum : nullptr, + valveQuirks + ); + + return (outFormat == intermediateWrite) ? dstImg : convertImageDataToFormat(dstImg, intermediateWrite, outFormat, dstWidth, dstHeight); +} diff --git a/src/vtfpp/ImageConversion.cpp b/src/vtfpp/ImageConversion.cpp index 171305af..b4eded89 100644 --- a/src/vtfpp/ImageConversion.cpp +++ b/src/vtfpp/ImageConversion.cpp @@ -14,7 +14,6 @@ #include #include #include -#include #ifdef SOURCEPP_BUILD_WITH_TBB #include @@ -2377,520 +2376,3 @@ void ImageConversion::hableTonemapImageData(std::span imageData, Imag #undef VTFPP_TONEMAP_CASE } - -namespace vtfpp::detail::DistanceMapping { -namespace { - -using math::Vec2i; -using math::Vec2f32; - -enum Leaf : uint8_t { - IN, // leaf is opaque - OUT, // leaf is transparent - FIGUREITOUT, // leaf is mixed but small so you should brute force scan. -}; - -uint16_t i32tou16sat(int32_t i, int32_t cap = UINT16_MAX) { - return static_cast(std::clamp(i, 0, cap)); -} - -uint16_t ftoabsu16(float f) { - return i32tou16sat(static_cast(fabs(f))); -} - -using IndexFunction = std::function; - -int32_t indexClamped(Vec2i v, uint16_t w, uint16_t h, uint8_t pxLen, uint8_t alphaOffs) { - int32_t rX = i32tou16sat(v[0], w - 1); - int32_t rY = i32tou16sat(v[1], h - 1); - return (rY * w + rX) * pxLen + alphaOffs; -} - -int32_t indexReflected(Vec2i v, uint16_t w, uint16_t h, uint8_t pxLen, uint8_t alphaOffs) { - int32_t rX = v[0] >= w ? w - v[0] % w : v[0]; - int32_t rY = v[1] >= h ? h - v[1] % h : v[1]; - return (rY * w + rX) * pxLen + alphaOffs; -} - -int32_t indexWrapped(Vec2i v, uint16_t w, uint16_t h, uint8_t pxLen, uint8_t alphaOffs) { - int32_t rX = v[0] % w; - int32_t rY = v[1] % h; - return (rY * w + rX) * pxLen + alphaOffs; -} - -IndexFunction indexBy(ImageConversion::ResizeEdge edge) -{ - switch (edge) { - default: - break; - case ImageConversion::ResizeEdge::REFLECT: - return indexReflected; - case ImageConversion::ResizeEdge::WRAP: - return indexWrapped; - } - return indexClamped; -} - -// big ugly immutable record of everything pertaining to how we read alpha from an image -class SampleParam { -public: - SampleParam( - const float *srcImg, - uint16_t inWidth, - uint16_t inHeight, - uint16_t reduceX, - uint16_t reduceY, - uint8_t pxLen, - uint8_t alphaOffs, - float distanceSpread, - bool sampleCentered, - float alphaThreshold, - ImageConversion::ResizeEdge edge - ) - : srcImg(srcImg) - , pxLen(pxLen) - , alphaOffs(alphaOffs) - , imgWidth(inWidth) - , imgHeight(inHeight) - , reduceX(reduceX) - , reduceY(reduceY) - , searchRadius(ftoabsu16(2.0f * std::max(reduceX, reduceY) * distanceSpread)) - // the second power of two after search radius - , granularity(uint16_t{4} << ftoabsu16(ceil(log2(static_cast(this->searchRadius))))) - , offsX(sampleCentered ? reduceX / 2 : 0) - , offsY(sampleCentered ? reduceY / 2 : 0) - , alphaThreshold(alphaThreshold) - , indexFn(indexBy(edge)) - {} - - float sample(Vec2i v) const { - return this->srcImg[this->indexFn(v, this->imgWidth, this->imgHeight, this->pxLen, this->alphaOffs)]; - } - - Leaf thresh(float alpha) const { - return alpha >= this->alphaThreshold ? IN : OUT; - } - - const float *const srcImg; - const float alphaThreshold; - const uint16_t imgHeight; - const uint16_t imgWidth; - const uint16_t searchRadius; - const uint16_t granularity; - const uint16_t offsX, offsY; - const uint16_t reduceX, reduceY; - const uint8_t pxLen; - const uint8_t alphaOffs; - const IndexFunction indexFn; -}; - -// immutable map detailing where we can skip computation -struct QuadTree { - QuadTree(const SampleParam *param, uint32_t &totalComputation) : root(this->scanImg(0, 0, param->imgWidth, param->imgHeight, param, totalComputation)) - {} - ~QuadTree() { - Node::wither(root); - } - - struct Node; - using Branch = std::variant; - struct Node { - const std::array branches; - Node(Branch nw, Branch ne, Branch sw, Branch se) : branches {nw, ne, sw, se} {}; - ~Node() { - for ( Branch branch : this->branches ) { - wither(branch); - } - } - static void wither(Branch c) { - if (holds_alternative(c)) { - delete get(c); - } - } - }; - - static bool brancheq(Branch a, Branch b) { - return holds_alternative(a) && holds_alternative(b) && get(a) == get(b); - } - - const Branch root; -private: - // TODO: cleverer scan to partition top-down? - static Branch scanImg(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const SampleParam *param, uint32_t &totalComputation) { - if (w <= param->granularity || h <= param->granularity) { - // extend sensitivity to minimum safe distance. a safe leaf is a safe leaf, no need to peek. - int32_t ix = x - param->searchRadius + param->offsX, tx = x + w + param->searchRadius + param->offsX; - int32_t iy = y - param->searchRadius + param->offsY, ty = y + h + param->searchRadius + param->offsY; - Leaf curShade = param->thresh(param->sample({ix, iy})); - - for (auto jx = ix; jx < tx; jx++) { - for (auto jy = iy; jy < ty; jy++) { - if (param->thresh(param->sample({jx, jy})) != curShade) { - totalComputation += w / param->reduceX * h / param->reduceY; - return FIGUREITOUT; - } - } - } - - return curShade; - } - - uint16_t hw = w / 2, hh = h / 2; - Branch iNW = scanImg(x, y, hw, hh, param, totalComputation); - Branch iNE = scanImg(x + hw, y, hw, hh, param, totalComputation); - Branch iSW = scanImg(x, y + hh, hw, hh, param, totalComputation); - Branch iSE = scanImg(x + hw, y + hh, hw, hh, param, totalComputation); - - if (brancheq(iNW, iNE) && brancheq(iNE, iSW) && brancheq(iSW, iSE)) { - return get(iNW); - } - return new Node(iNW, iNE, iSW, iSE); - } -}; - -// traverse a QuadTree at one row of a leaf at a time -class RasterCursor { -public: - RasterCursor(const QuadTree *tree, uint16_t imgWidth, uint16_t imgHeight, uint16_t reduceX = 1, uint16_t reduceY = 1, uint16_t offsX = 0, uint16_t offsY = 0) - : tree(tree) - , imgWidth(imgWidth) - , imgHeight(imgHeight) - , reduceX(reduceX) - , reduceY(reduceY) - , offsX(offsX) - , curX(offsX) - , curY(offsY) - { - this->curShade = this->reorient(); - } - RasterCursor(const QuadTree *tree, const SampleParam *param) - : RasterCursor(tree, param->imgWidth, param->imgHeight, param->reduceX, param->reduceY, param->offsX, param->offsY) - {} - - bool getContiguousRun(Leaf &srcShade, uint16_t &srcY, uint16_t &srcX, uint16_t &dstW) { - srcX = this->curX; - srcY = this->curY; - dstW = 0; - srcShade = this->curShade = this->reorient(); - - while (this->curX < this->imgWidth && QuadTree::brancheq(srcShade, this->curShade)) { - auto blkSize = this->imgWidth >> this->depth; - this->curX += blkSize; - dstW += blkSize / this->reduceX; - this->curShade = this->reorient(); - } - - if (this->curX >= this->imgWidth) { - this->curX = this->offsX; - this->curY += this->reduceY; - } - - return this->curY < this->imgHeight; - } -private: - Leaf reorient() { - this->depth = 0; - this->curBranch = this->tree->root; - auto remX = this->curX, remY = this->curY; - - while (std::holds_alternative(this->curBranch)) { - auto benchX = this->imgWidth >> (this->depth + 1), benchY = this->imgHeight >> (this->depth + 1); - if (benchX < 2 || benchY < 2) { - return this->curShade; // would be nice to make this unrepresentable - } - auto bGtX = remX >= benchX, bGtY = remY >= benchY; - remX -= benchX * bGtX; - remY -= benchY * bGtY; - this->curBranch = std::get(this->curBranch)->branches[bGtX | bGtY << 1]; - this->depth++; - } - - return get(this->curBranch); - } - - const QuadTree *tree; - uint16_t curX, curY, depth; - const uint16_t imgWidth, imgHeight, reduceX, reduceY, offsX; - Leaf curShade; - QuadTree::Branch curBranch; -}; - -float vecDist(Vec2f32 v0, Vec2f32 v1) { - return std::hypot(v0[0] - v1[0], v1[0] - v1[1]); -} -Vec2i vec2ipmul(Vec2i v0, Vec2i v1) { - return {v0[0] * v1[0], v0[1] * v1[1]}; -} - -using math::pi_f32; - -void paintMap( - const SampleParam *param, - float *dstImg, - uint8_t dstPxLen, - uint8_t dstAlphaOffs, - bool distanceAA, - bool euclidean, - const float *tangentDiffusion, - bool *valveQuirks -) { - uint32_t totalComputation = 0; - - const QuadTree tree(param, totalComputation); - RasterCursor cursor(&tree, param); - - uint32_t iOut = dstAlphaOffs; - Leaf srcShade = FIGUREITOUT; - uint16_t srcY = 0, srcX = 0, dstW = 0; - - float fRadius = static_cast(param->searchRadius); - uint16_t dstWidth = param->imgWidth / param->reduceX; - uint16_t dstHeight = param->imgHeight / param->reduceY; - - uint32_t iGradW = 0; - std::vector gradients = tangentDiffusion ? std::vector(totalComputation, -pi_f32 - 0.05f) : std::vector(0); - float lastAng = 0; - float hypot1 = 1; - - bool keepPainting = true; - do { - keepPainting = cursor.getContiguousRun(srcShade, srcY, srcX, dstW); - float fillDistance = 1.0f; - switch (srcShade) { - case OUT: - fillDistance = 0.0f; - case IN: - for (uint16_t i = 0; i < dstW; i++, iOut += dstPxLen) { - dstImg[iOut] = fillDistance; - } - break; - case FIGUREITOUT: - default: - // oh no we actually have to do work - for (uint16_t i = 0; i < dstW; i++, iGradW++, iOut += dstPxLen) { - int32_t curX = srcX + i * param->reduceX; - float alphaRef = param->sample({curX, srcY}); - Leaf stateRef = param->thresh(alphaRef); - float nearest = std::numeric_limits::max(); - for (int32_t sX = curX - param->searchRadius, tX = curX + param->searchRadius; sX < tX; sX++) { - for (int32_t sY = srcY - param->searchRadius, tY = srcY + param->searchRadius; sY < tY; sY++) { - float alphaSmp = param->sample({sX, sY}); - Leaf stateSmp = param->thresh(alphaSmp); - if (stateSmp != stateRef) { - float dist = std::hypot(sX - curX, sY - srcY); - - if (tangentDiffusion || distanceAA || euclidean) { - lastAng = atan2(sX - curX, sY - srcY); - } - if (distanceAA || euclidean) { - hypot1 = 1 / cos(fabs(fmod(lastAng + pi_f32 * 2.25f, pi_f32 / 2.0f) - pi_f32 / 4.0f)); - } - if (distanceAA) { - dist += hypot1 * (1 - fabs(alphaSmp - alphaRef)); - } - if (euclidean && dist > fRadius + hypot1) { - continue; - } - nearest = fmin(nearest, dist); - } - } - } - - nearest = fmin(0.5f, nearest * 0.5f / fRadius); - if (stateRef == OUT) { - nearest = -nearest; - } - - dstImg[iOut] = 0.5 + nearest; - if (tangentDiffusion) { - gradients[iGradW] = lastAng; - } - } - } - } while (keepPainting); - - if (tangentDiffusion) { - // attempt to diffuse quantization error tangent to the gradient of the - // distance map. you could imagine some approach that involves grouping - // greyscale pixels into distinct contours, and playing ring around the - // rosy for each group, but there's no practical way to avoid littering - // residual bits of error into pixels you've already quantized, ruining - // the whole point. the usual suspects like floyd-steinberg use kernels - // shaped to only write *ahead* of a conventional raster scan. here, we - // do the same by determining a shape, bounded by the following "mask": - // - // ⎡ 0 0 0 ⎤ - // ⎢ 0 0 1 ⎥ - // ⎣ 1 1 1 ⎦, - // - // mapping adjacent pixels to the unit circle's bounding box like this: - // - // ⎡ NW:(-1, 1) N:(0, 1) NE:(1, 1) ⎤ - // ⎢ W:(-1, 0) E:(1, 0) ⎥ - // ⎣ SW:(-1, -1) S:(0, -1) SE:(1, -1) ⎦, - // - // choose the two points nearest (x, y) on the unit circle at θ + π / 2 - // (i.e. the right-hand normal of the gradient direction θ). distribute - // the quantization error E between the two, weighted by the opposite's - // distance over the sum of both distances (it feels as though a better - // weighting should exist but that's what i get for dropping out), then - // fold the whole thing over to only entries ahead of us in scan order: - // - // ⎡ ⎤ - // k = ⎢ E+W ⎥ - // ⎣ SW+NE S+N SE+NW ⎦, - // - // yielding our per-destination-pixel kernel k. a neat property of this - // is that we can continue ignoring pixels we previously ignored thanks - // to the quadtree (and thus, never set any angle for): we're diffusing - // error tangent to the gradient, and that way lie only pixels where we - // also already set an angle. we can index angles independently, which, - // depending on the contents of the source image, might require a small - // fraction of the space compared to the distance map (or just as much) - static constexpr std::array hardSigns{1, 1, 0, -1, -1, -1, 0, 1}; - // of course, in our case, y grows downward, so we really want is E..NW - // which, conveniently enough, is just 0..3, so no aux lookup table. if - // we wanted SW..E, we'd index into {0, 5, 6, 7}. - static constexpr float eighth = pi_f32 / 4.0f; - - RasterCursor diffCursor(&tree, dstWidth, dstHeight); - uint32_t diffOut = dstAlphaOffs; - Leaf diffShade = FIGUREITOUT; - const uint32_t dstBufLen = dstWidth * dstHeight * dstPxLen; - uint16_t diffY = 0, diffX = 0, diffW = 0; - uint32_t iGradR = 0; - - bool keepDiffusing = true; - do { - keepDiffusing = diffCursor.getContiguousRun(diffShade, diffY, diffX, diffW); - if (diffShade != FIGUREITOUT) { - continue; - } - bool rightEdge = diffX + diffW >= dstWidth; - - for (uint16_t i = 0; i < diffW; i++, iGradR++, diffOut += dstPxLen) { - float theta = gradients[iGradR]; - if (theta < -pi_f32 - 0.025f) { - continue; - } - float thetaNormal = fmod(theta + 2.5f * pi_f32, 2.0f * pi_f32); - int8_t nEighths = (thetaNormal - fmod(thetaNormal, eighth)) / eighth; - float quantErr = fmod(dstImg[diffOut], *tangentDiffusion); - dstImg[diffOut] -= quantErr; - bool atRight = rightEdge && i >= diffW - 1; - bool bottomEdge = diffY >= dstHeight - 1; - if (atRight && bottomEdge) { - break; - } - Vec2f32 normal{cos(thetaNormal), sin(thetaNormal)}; - // distance calculation uses the whole circle - float dist0 = vecDist(normal, {hardSigns[(nEighths + 0) % 8], hardSigns[(nEighths + 2) % 8]}); - float dist1 = vecDist(normal, {hardSigns[(nEighths + 1) % 8], hardSigns[(nEighths + 3) % 8]}); - // offset selection uses the part of the circle that is yet to be scanned - Vec2i offs0{hardSigns[(nEighths + 0) % 4], hardSigns[(nEighths + 2) % 4]}; - Vec2i offs1{hardSigns[(nEighths + 1) % 4], hardSigns[(nEighths + 3) % 4]}; - Vec2i pos{diffX + i, diffY}; - if (bottomEdge) { - offs0 = vec2ipmul(offs0, {1, 0}); - offs1 = vec2ipmul(offs1, {1, 0}); - } - dstImg[param->indexFn(pos + offs0, dstWidth, dstHeight, dstPxLen, dstAlphaOffs)] += quantErr * dist1 / (dist0 + dist1); - dstImg[param->indexFn(pos + offs1, dstWidth, dstHeight, dstPxLen, dstAlphaOffs)] += quantErr * dist0 / (dist0 + dist1); - } - } while (keepDiffusing); - } - - if (valveQuirks) { - *valveQuirks = false; - - auto blackOutAndWarn = [&](auto oi) { - float& px = dstImg[oi * dstPxLen + dstAlphaOffs]; - if (fabs(px) > 0.000001f) { - *valveQuirks = true; - } - px = 0.0f; - }; - - for (auto x = 0; x < dstWidth; x++) { - blackOutAndWarn(x); - blackOutAndWarn(x + (dstHeight - 1) * dstWidth); - } - for (auto y = 0; y < dstHeight; y++) { - blackOutAndWarn(y * dstWidth); - blackOutAndWarn(y * dstWidth + dstHeight - 1); - } - } -} - -bool operator!(ImageConversion::DistanceFlags flags) { - return flags == ImageConversion::DistanceFlags::NONE; -}; - -bool isSingleChannel(ImageFormat format) { - using namespace ImageFormatDetails; - return red(format) == bpp(format) && bpp(format); -} - -} // namespace -} // namespace vtfpp::detail::DistanceMapping - -[[nodiscard]] std::vector ImageConversion::alphaToDistance(std::span imageData, ImageFormat inFormat, ImageFormat outFormat, uint16_t width, uint16_t height, uint16_t reduceX, uint16_t reduceY, bool srgb, float distanceSpread, float alphaThreshold, ImageConversion::DistanceFlags flags, ImageConversion::DistanceDither dither, ImageConversion::ResizeFilter filter, ImageConversion::ResizeEdge edge, bool *valveQuirks) { - using namespace vtfpp::detail::DistanceMapping; - using namespace ImageFormatDetails; - - auto mkFormat = [](ImageFormat format) { - return isSingleChannel(format) ? - std::make_tuple(ImageFormat::R32F, uint8_t{1}, uint8_t{0}) - : (decompressedAlpha(format) || alpha(format)) ? - std::make_tuple(ImageFormat::RGBA32323232F, uint8_t{4}, uint8_t{3}) - : - std::make_tuple(ImageFormat::EMPTY, uint8_t{0}, uint8_t{0}); - }; - auto [intermediateRead, srcPxLen, srcAlphaOffs] = mkFormat(inFormat); - auto [intermediateWrite, dstPxLen, dstAlphaOffs] = mkFormat(outFormat); - - if ( - intermediateRead == ImageFormat::EMPTY - || intermediateWrite == ImageFormat::EMPTY - || !math::isPowerOf2(reduceX) - || !math::isPowerOf2(reduceY) - || ftoabsu16(distanceSpread * reduceX) < 1 - || ftoabsu16(distanceSpread * reduceY) < 1 - || std::clamp(alphaThreshold, 0.0f, 1.0f) != alphaThreshold - || edge == ImageConversion::ResizeEdge::ZERO - ) { - return {}; - } - - bool inputNeedsConversion = intermediateRead != inFormat; - std::vector i_love_raii = inputNeedsConversion ? convertImageDataToFormat(imageData, inFormat, intermediateRead, width, height) : std::vector(0); - std::span srcImg = inputNeedsConversion ? i_love_raii : imageData; - - SampleParam param(reinterpret_cast(srcImg.data()), width, height, reduceX, reduceY, srcPxLen, srcAlphaOffs, distanceSpread, !!(flags & DistanceFlags::SAMPLECENTERED), alphaThreshold, edge); - - uint16_t dstWidth = width / reduceX, dstHeight = height / reduceY; - - std::vector dstImg - = (!isSingleChannel(inFormat) && !isSingleChannel(outFormat)) ? - convertImageDataToFormat( - resizeImageData(imageData, inFormat, width, dstWidth, height, dstHeight, srgb, true, filter, edge), - inFormat, intermediateWrite, dstWidth, dstHeight - ) - : - std::vector(sizeof(float) * dstPxLen * dstWidth * dstHeight, std::byte{0x00}); - - float quantum = 1.0f / static_cast(1 << decompressedAlpha(outFormat)); - bool doGradientDither = dither == DistanceDither::GRADIENT_TANGENT && !decimal(outFormat); - paintMap( - ¶m, - reinterpret_cast(dstImg.data()), - dstPxLen, - dstAlphaOffs, - !!(flags & DistanceFlags::DISTANCEAA), - !!(flags & DistanceFlags::EUCLIDEAN), - doGradientDither ? &quantum : nullptr, - valveQuirks - ); - - return (outFormat == intermediateWrite) ? dstImg : convertImageDataToFormat(dstImg, intermediateWrite, outFormat, dstWidth, dstHeight); -} diff --git a/src/vtfpp/_vtfpp.cmake b/src/vtfpp/_vtfpp.cmake index 9be5bb3a..9735c203 100644 --- a/src/vtfpp/_vtfpp.cmake +++ b/src/vtfpp/_vtfpp.cmake @@ -1,6 +1,7 @@ add_pretty_parser(vtfpp DEPS bcdec libzstd_static miniz sourcepp_compression sourcepp_parser sourcepp_stb PRECOMPILED_HEADERS + "${CMAKE_CURRENT_SOURCE_DIR}/include/vtfpp/DistanceMapping.h" "${CMAKE_CURRENT_SOURCE_DIR}/include/vtfpp/HOT.h" "${CMAKE_CURRENT_SOURCE_DIR}/include/vtfpp/ImageConversion.h" "${CMAKE_CURRENT_SOURCE_DIR}/include/vtfpp/ImageFormats.h" @@ -14,6 +15,7 @@ add_pretty_parser(vtfpp "${CMAKE_CURRENT_SOURCE_DIR}/include/vtfpp/VTF.h" "${CMAKE_CURRENT_SOURCE_DIR}/include/vtfpp/vtfpp.h" SOURCES + "${CMAKE_CURRENT_LIST_DIR}/DistanceMapping.cpp" "${CMAKE_CURRENT_LIST_DIR}/HOT.cpp" "${CMAKE_CURRENT_LIST_DIR}/ImageConversion.cpp" "${CMAKE_CURRENT_LIST_DIR}/ImageQuantize.cpp" From 7454f492d8436996f8be116f150278bc0a00995e Mon Sep 17 00:00:00 2001 From: yuuko Date: Sun, 17 May 2026 18:32:29 -0700 Subject: [PATCH 3/8] vtfpp: DistanceMapping: correct wrapped sampling --- src/vtfpp/DistanceMapping.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vtfpp/DistanceMapping.cpp b/src/vtfpp/DistanceMapping.cpp index d720d69b..905eae7d 100644 --- a/src/vtfpp/DistanceMapping.cpp +++ b/src/vtfpp/DistanceMapping.cpp @@ -45,8 +45,8 @@ int32_t indexReflected(Vec2i v, uint16_t w, uint16_t h, uint8_t pxLen, uint8_t a } int32_t indexWrapped(Vec2i v, uint16_t w, uint16_t h, uint8_t pxLen, uint8_t alphaOffs) { - int32_t rX = v[0] % w; - int32_t rY = v[1] % h; + int32_t rX = (v[0] + w) % w; + int32_t rY = (v[1] + h) % h; return (rY * w + rX) * pxLen + alphaOffs; } From 422559599ce31ddb6af371dab4c920c159a84470 Mon Sep 17 00:00:00 2001 From: yuuko Date: Sun, 17 May 2026 18:37:52 -0700 Subject: [PATCH 4/8] vtfpp: add DistanceMapping tests --- test/vtfpp.cpp | 130 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/test/vtfpp.cpp b/test/vtfpp.cpp index 50505be1..cbb29e6a 100644 --- a/test/vtfpp.cpp +++ b/test/vtfpp.cpp @@ -1574,3 +1574,133 @@ TEST(vtfpp, read_v76_nomip_c9) { EXPECT_EQ(image->flags, Resource::FLAG_NONE); EXPECT_EQ(image->data.size(), ImageFormatDetails::getDataLength(vtf.getFormat(), vtf.getMipCount(), vtf.getFrameCount(), vtf.getFaceCount(), vtf.getWidth(), vtf.getHeight(), vtf.getDepth())); } + +namespace distancealpha_testing { + static const uint16_t w = 1024; + static const uint16_t h = 1024; + static const uint16_t reduceX = 4; + static const uint16_t reduceY = 4; + static const uint16_t dstW = w / reduceX; + static const uint16_t dstH = h / reduceY; + + template + T &reference(std::span s, uint16_t x_, uint16_t y_, uint16_t width, uint8_t pxLen = 1, uint8_t alphaOffs = 0) { + return s[pxLen * (y_ * width + x_) + alphaOffs]; + } + std::byte sample(std::span s, uint16_t x_, uint16_t y_, uint16_t width, uint8_t pxLen = 1, uint8_t alphaOffs = 0) { + return s[pxLen * (y_ * width + x_) + alphaOffs]; + } +}; + +TEST(vtfpp, distancealpha_edge_mask_true) { + using namespace distancealpha_testing; + using namespace DistanceMapping; + + std::vector bw(w * h, 0.0f); + for (uint16_t y = h / 2; y < h; y++) { + for (uint16_t x = 0; x < w; x++) { + reference(bw, x, y, h) = 1.0f; + } + } + + bool valveQuirks = false; + + std::vector mapped = alphaToDistance( + std::span(reinterpret_cast(bw.data()), w * h * sizeof(float)), + ImageFormat::R32F, + ImageFormat::RGBA8888, + w, + h, + reduceX, + reduceY, + false, + 1.0f, + 0.04f, + Flags::NONE, + Dither::NONE, + ImageConversion::ResizeFilter::NICE, + ImageConversion::ResizeEdge::CLAMP, + &valveQuirks + ); + EXPECT_EQ(std::byte{0x00}, sample(mapped, 0, 0, dstW, 4, 3)); + EXPECT_EQ(std::byte{0x00}, sample(mapped, dstW - 1, dstH - 1, dstW, 4, 3)); + EXPECT_EQ(std::byte{0xFF}, sample(mapped, dstW / 2, dstH / 4 * 3, dstW, 4, 3)); + EXPECT_NE(std::byte{0x00}, sample(mapped, dstW / 2, dstH / 2, dstW, 4, 3)); + EXPECT_NE(std::byte{0xFF}, sample(mapped, dstW / 2, dstH / 2, dstW, 4, 3)); + ASSERT_TRUE(valveQuirks); +} + + +TEST(vtfpp, distancealpha_edge_mask_false) { + using namespace distancealpha_testing; + using namespace DistanceMapping; + + std::vector circle(w * h, 0.0f); + for (uint16_t y = 0; y < h; y++) { + for (uint16_t x = 0; x < w; x++) { + if (std::hypot(fabs(512 - y), fabs(512 - x)) < 320.0f) { + reference(circle, x, y, h) = 1.0f; + } + } + } + + bool valveQuirks = true; + + std::vector mapped = alphaToDistance( + std::span(reinterpret_cast(circle.data()), w * h * sizeof(float)), + ImageFormat::R32F, + ImageFormat::RGBA8888, + w, + h, + reduceX, + reduceY, + false, + 1.0f, + 0.04f, + Flags::NONE, + Dither::NONE, + ImageConversion::ResizeFilter::NICE, + ImageConversion::ResizeEdge::CLAMP, + &valveQuirks + ); + EXPECT_EQ(std::byte{0x00}, sample(mapped, 0, 0, dstW, 4, 3)); + EXPECT_EQ(std::byte{0x00}, sample(mapped, dstW - 1, dstH - 1, dstW, 4, 3)); + EXPECT_EQ(std::byte{0xFF}, sample(mapped, dstW / 2, dstH / 2, dstW, 4, 3)); + ASSERT_FALSE(valveQuirks); +} + +TEST(vtfpp, distancealpha_wrap_sample) { + using namespace distancealpha_testing; + using namespace DistanceMapping; + + std::vector bw(w * h, 0.0f); + for (uint16_t y = h / 2; y < h; y++) { + for (uint16_t x = 0; x < w; x++) { + reference(bw, x, y, h) = 1.0f; + } + } + + std::vector mapped = alphaToDistance( + std::span(reinterpret_cast(bw.data()), w * h * sizeof(float)), + ImageFormat::R32F, + ImageFormat::RGBA8888, + w, + h, + reduceX, + reduceY, + false, + 1.0f, + 0.04f, + Flags::NONE, + Dither::NONE, + ImageConversion::ResizeFilter::NICE, + ImageConversion::ResizeEdge::WRAP, + nullptr + ); + EXPECT_NE(std::byte{0x00}, sample(mapped, 0, 0, dstW, 4, 3)); + EXPECT_NE(std::byte{0x00}, sample(mapped, dstW - 1, 0, dstW, 4, 3)); + EXPECT_EQ(std::byte{0x00}, sample(mapped, dstW / 2, dstH / 4, dstW, 4, 3)); + EXPECT_EQ(std::byte{0xFF}, sample(mapped, dstW / 2, dstH / 4 * 3, dstW, 4, 3)); + EXPECT_NE(std::byte{0x00}, sample(mapped, dstW / 2, dstH / 2, dstW, 4, 3)); + EXPECT_NE(std::byte{0xFF}, sample(mapped, dstW / 2, dstH / 2, dstW, 4, 3)); +} From 65f5507bce42016715aa782743cd103d3dd4fbec Mon Sep 17 00:00:00 2001 From: craftablescience Date: Sun, 17 May 2026 21:44:53 -0700 Subject: [PATCH 5/8] vtfpp: exceedingly light formatting tweaks, document contribution --- README.md | 1 + docs/index.html | 1 + include/vtfpp/DistanceMapping.h | 12 +++---- src/vtfpp/DistanceMapping.cpp | 2 +- test/vtfpp.cpp | 62 +++++++++++++++++---------------- 5 files changed, 40 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index d2dd2d68..0f8d0954 100644 --- a/README.md +++ b/README.md @@ -524,6 +524,7 @@ These are only the tools and games using `sourcepp` that I know of. If you would - `vtfpp` - NICE/Lanczos-3 resize filter's initial implementation was contributed by [@koerismo](https://github.com/koerismo). - Big-endian support is based on work by [@partyvan](https://github.com/partyvan). + - Distance to alpha support was contributed by [@partyvan](https://github.com/partyvan). - SHT parser/writer was contributed by [@Trico Everfire](https://github.com/Trico-Everfire). - Initial VTF write support was loosely based on work by [@Trico Everfire](https://github.com/Trico-Everfire). - HDRI to cubemap conversion code is modified from the [HdriToCubemap](https://github.com/ivarout/HdriToCubemap) library by [@ivarout](https://github.com/ivarout). diff --git a/docs/index.html b/docs/index.html index e1de45bc..4dda27ab 100644 --- a/docs/index.html +++ b/docs/index.html @@ -895,6 +895,7 @@

Special Thanks

  • NICE/Lanczos-3 resize filter's initial implementation was contributed by @koerismo.
  • Big-endian support is based on work by @partyvan.
  • +
  • Distance to alpha support was contributed by @partyvan.
  • SHT parser/writer was contributed by @Trico Everfire.
  • Initial VTF write support was loosely based on work by @Trico Everfire.
  • HDRI to cubemap conversion code is modified from the HdriToCubemap library by @ivarout.
  • diff --git a/include/vtfpp/DistanceMapping.h b/include/vtfpp/DistanceMapping.h index 31227f6e..6f36691a 100644 --- a/include/vtfpp/DistanceMapping.h +++ b/include/vtfpp/DistanceMapping.h @@ -1,10 +1,9 @@ #pragma once -#include - #include #include +#include namespace vtfpp::DistanceMapping { @@ -20,7 +19,6 @@ enum class Flags : uint32_t { EUCLIDEAN = 1 << 1, ///< The distance-mapping algorithm is a brute-force scan of a *square* area. If this is enabled, only accept distance hits in a circular area. SAMPLECENTERED = 1 << 2, ///< Search from the center of pixels (in destination coordinate space) rather than in their north-west corners. Can mitigate a perceived southeast shift at extreme reductions. }; - SOURCEPP_BITFLAGS_ENUM(Flags) /// In one operation, convert an image's alpha channel, or, for single-channel formats, its only channel, to a VTEX-style distance map, and downscale other channels, if present in the output, according to the given resize parameters. @@ -38,8 +36,8 @@ SOURCEPP_BITFLAGS_ENUM(Flags) /// * 0 in all other channels. [[nodiscard]] std::vector alphaToDistance( std::span imageData, - vtfpp::ImageFormat inFormat, ///< Any format that is either single-channel, or has an alpha channel. - vtfpp::ImageFormat outFormat, ///< The same requirements as inFormat; channel count does not need to correspond to inFormat. + ImageFormat inFormat, ///< Any format that is either single-channel, or has an alpha channel. + ImageFormat outFormat, ///< The same requirements as inFormat; channel count does not need to correspond to inFormat. uint16_t width, uint16_t height, uint16_t reduceX, ///< Power-of-two horizontal reduction factor. @@ -51,7 +49,7 @@ SOURCEPP_BITFLAGS_ENUM(Flags) Dither dither = Dither::NONE, ///< Internally, distance maps are always computed in floating point. When set to a value other than NONE, and the output format is of integral type, dithering is applied prior to quantization. Depending on the application and contents, this can improve or worsen the quality of the distance map. ImageConversion::ResizeFilter filter = ImageConversion::ResizeFilter::NICE, ///< Default value mimics VTEX. Does not affect the alpha channel; distance mapping is a distinct sampling operation from source to destination space. Entirely unused if input is single-channel. ImageConversion::ResizeEdge edge = ImageConversion::ResizeEdge::CLAMP, ///< Dictates the sampling policy of the distance function regardless of whether there are non-alpha channels to be resized. - bool *valveQuirks = nullptr ///< When non-null, mimic VTEX's policy of blanking out any edge pixels in the distance map to 0 (i.e. infinite distance), and report back whether this resulted in any change (such that a command-line tool emulating VTEX would want to report a warning). + bool* valveQuirks = nullptr ///< When non-null, mimic VTEX's policy of blanking out any edge pixels in the distance map to 0 (i.e. infinite distance), and report back whether this resulted in any change (such that a command-line tool emulating VTEX would want to report a warning). ); -} // namespace DistanceMapping +} // namespace vtfpp::DistanceMapping diff --git a/src/vtfpp/DistanceMapping.cpp b/src/vtfpp/DistanceMapping.cpp index 905eae7d..62e74b7e 100644 --- a/src/vtfpp/DistanceMapping.cpp +++ b/src/vtfpp/DistanceMapping.cpp @@ -1,10 +1,10 @@ #include -#include #include #include #include +#include using namespace sourcepp; using namespace vtfpp; diff --git a/test/vtfpp.cpp b/test/vtfpp.cpp index cbb29e6a..64da9f14 100644 --- a/test/vtfpp.cpp +++ b/test/vtfpp.cpp @@ -1575,38 +1575,41 @@ TEST(vtfpp, read_v76_nomip_c9) { EXPECT_EQ(image->data.size(), ImageFormatDetails::getDataLength(vtf.getFormat(), vtf.getMipCount(), vtf.getFrameCount(), vtf.getFaceCount(), vtf.getWidth(), vtf.getHeight(), vtf.getDepth())); } -namespace distancealpha_testing { - static const uint16_t w = 1024; - static const uint16_t h = 1024; - static const uint16_t reduceX = 4; - static const uint16_t reduceY = 4; - static const uint16_t dstW = w / reduceX; - static const uint16_t dstH = h / reduceY; - - template - T &reference(std::span s, uint16_t x_, uint16_t y_, uint16_t width, uint8_t pxLen = 1, uint8_t alphaOffs = 0) { - return s[pxLen * (y_ * width + x_) + alphaOffs]; - } - std::byte sample(std::span s, uint16_t x_, uint16_t y_, uint16_t width, uint8_t pxLen = 1, uint8_t alphaOffs = 0) { - return s[pxLen * (y_ * width + x_) + alphaOffs]; - } -}; +namespace TestDistanceMapping { + +constexpr uint16_t w = 1024; +constexpr uint16_t h = 1024; +constexpr uint16_t reduceX = 4; +constexpr uint16_t reduceY = 4; +constexpr uint16_t dstW = w / reduceX; +constexpr uint16_t dstH = h / reduceY; + +template +[[nodiscard]] T& reference(std::span s, uint16_t x_, uint16_t y_, uint16_t width, uint8_t pxLen = 1, uint8_t alphaOffs = 0) { + return s[pxLen * (y_ * width + x_) + alphaOffs]; +} + +[[nodiscard]] std::byte sample(std::span s, uint16_t x_, uint16_t y_, uint16_t width, uint8_t pxLen = 1, uint8_t alphaOffs = 0) { + return s[pxLen * (y_ * width + x_) + alphaOffs]; +} + +} // namespace TestDistanceMapping TEST(vtfpp, distancealpha_edge_mask_true) { - using namespace distancealpha_testing; + using namespace TestDistanceMapping; using namespace DistanceMapping; - std::vector bw(w * h, 0.0f); + std::vector bw(w * h); for (uint16_t y = h / 2; y < h; y++) { for (uint16_t x = 0; x < w; x++) { - reference(bw, x, y, h) = 1.0f; + reference(bw, x, y, h) = 1.f; } } bool valveQuirks = false; std::vector mapped = alphaToDistance( - std::span(reinterpret_cast(bw.data()), w * h * sizeof(float)), + {reinterpret_cast(bw.data()), w * h * sizeof(float)}, ImageFormat::R32F, ImageFormat::RGBA8888, w, @@ -1630,16 +1633,15 @@ TEST(vtfpp, distancealpha_edge_mask_true) { ASSERT_TRUE(valveQuirks); } - TEST(vtfpp, distancealpha_edge_mask_false) { - using namespace distancealpha_testing; + using namespace TestDistanceMapping; using namespace DistanceMapping; - std::vector circle(w * h, 0.0f); + std::vector circle(w * h); for (uint16_t y = 0; y < h; y++) { for (uint16_t x = 0; x < w; x++) { - if (std::hypot(fabs(512 - y), fabs(512 - x)) < 320.0f) { - reference(circle, x, y, h) = 1.0f; + if (std::hypot(fabs(512 - y), fabs(512 - x)) < 320.f) { + reference(circle, x, y, h) = 1.f; } } } @@ -1647,7 +1649,7 @@ TEST(vtfpp, distancealpha_edge_mask_false) { bool valveQuirks = true; std::vector mapped = alphaToDistance( - std::span(reinterpret_cast(circle.data()), w * h * sizeof(float)), + {reinterpret_cast(circle.data()), w * h * sizeof(float)}, ImageFormat::R32F, ImageFormat::RGBA8888, w, @@ -1670,18 +1672,18 @@ TEST(vtfpp, distancealpha_edge_mask_false) { } TEST(vtfpp, distancealpha_wrap_sample) { - using namespace distancealpha_testing; + using namespace TestDistanceMapping; using namespace DistanceMapping; - std::vector bw(w * h, 0.0f); + std::vector bw(w * h); for (uint16_t y = h / 2; y < h; y++) { for (uint16_t x = 0; x < w; x++) { - reference(bw, x, y, h) = 1.0f; + reference(bw, x, y, h) = 1.f; } } std::vector mapped = alphaToDistance( - std::span(reinterpret_cast(bw.data()), w * h * sizeof(float)), + {reinterpret_cast(bw.data()), w * h * sizeof(float)}, ImageFormat::R32F, ImageFormat::RGBA8888, w, From b28185eb3863f62b0b0be4a1f1de4875c57ef892 Mon Sep 17 00:00:00 2001 From: craftablescience Date: Sun, 17 May 2026 22:14:01 -0700 Subject: [PATCH 6/8] c: distance mapping bindings --- include/vtfpp/DistanceMapping.h | 6 +-- lang/c/include/vtfppc/DistanceMapping.h | 56 +++++++++++++++++++++++++ lang/c/include/vtfppc/vtfpp.h | 1 + lang/c/src/gameppc/_gameppc.cmake | 1 + lang/c/src/steamppc/_steamppc.cmake | 1 + lang/c/src/vcryptppc/_vcryptppc.cmake | 1 + lang/c/src/vpkppc/_vpkppc.cmake | 1 + lang/c/src/vtfppc/DistanceMapping.cpp | 26 ++++++++++++ lang/c/src/vtfppc/_vtfppc.cmake | 3 ++ 9 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 lang/c/include/vtfppc/DistanceMapping.h create mode 100644 lang/c/src/vtfppc/DistanceMapping.cpp diff --git a/include/vtfpp/DistanceMapping.h b/include/vtfpp/DistanceMapping.h index 6f36691a..92199a0b 100644 --- a/include/vtfpp/DistanceMapping.h +++ b/include/vtfpp/DistanceMapping.h @@ -14,7 +14,7 @@ enum class Dither { }; enum class Flags : uint32_t { - NONE = 0, + NONE = 0, DISTANCEAA = 1 << 0, ///< Experimental; interpret the alpha channel as antialiased. Can result in a more precise distance map, but produces nonsense if large gradients are present. EUCLIDEAN = 1 << 1, ///< The distance-mapping algorithm is a brute-force scan of a *square* area. If this is enabled, only accept distance hits in a circular area. SAMPLECENTERED = 1 << 2, ///< Search from the center of pixels (in destination coordinate space) rather than in their north-west corners. Can mitigate a perceived southeast shift at extreme reductions. @@ -23,7 +23,7 @@ SOURCEPP_BITFLAGS_ENUM(Flags) /// In one operation, convert an image's alpha channel, or, for single-channel formats, its only channel, to a VTEX-style distance map, and downscale other channels, if present in the output, according to the given resize parameters. /// @return Empty if: -/// * \p inFormat or \p outFormat do not satisy their documented predicates, +/// * \p inFormat or \p outFormat do not satisfy their documented predicates, /// * \p reduceX or \p reduceY are not powers of two, /// * \p distanceSpread would result in a search radius of zero with either \p reduceX or \p reduceY, /// * \p alphaThreshold is out of range, or @@ -43,7 +43,7 @@ SOURCEPP_BITFLAGS_ENUM(Flags) uint16_t reduceX, ///< Power-of-two horizontal reduction factor. uint16_t reduceY, ///< Power-of-two vertical reduction factor. bool srgb, ///< Used in the case of RGBA->RGBA. premultipliedAlpha is omitted. - float distanceSpread = 1.0f, ///< Scale factor beyond \p reduceX and \p reduceY, of search radius for distance hits. + float distanceSpread = 1.f, ///< Scale factor beyond \p reduceX and \p reduceY, of search radius for distance hits. float alphaThreshold = 0.04f, ///< Threshold below which alpha values are considered zero. Flags flags = Flags::NONE, ///< Additional behaviors that deviate from VTEX but may be useful; see DistanceFlags. Dither dither = Dither::NONE, ///< Internally, distance maps are always computed in floating point. When set to a value other than NONE, and the output format is of integral type, dithering is applied prior to quantization. Depending on the application and contents, this can improve or worsen the quality of the distance map. diff --git a/lang/c/include/vtfppc/DistanceMapping.h b/lang/c/include/vtfppc/DistanceMapping.h new file mode 100644 index 00000000..d3526620 --- /dev/null +++ b/lang/c/include/vtfppc/DistanceMapping.h @@ -0,0 +1,56 @@ +#pragma once + +#include + +#include "API.h" +#include "ImageConversion.h" + +VTFPP_EXTERN typedef enum { + VTFPP_DISTANCE_MAPPING_DITHER_NONE = 0, + VTFPP_DISTANCE_MAPPING_DITHER_GRADIENT_TANGENT, +} vtfpp_distance_mapping_dither_e; + +VTFPP_EXTERN typedef enum { + VTFPP_DISTANCE_MAPPING_FLAG_NONE = 0, + VTFPP_DISTANCE_MAPPING_FLAG_DISTANCEAA = 1 << 0, + VTFPP_DISTANCE_MAPPING_FLAG_EUCLIDEAN = 1 << 1, + VTFPP_DISTANCE_MAPPING_FLAG_SAMPLECENTERED = 1 << 2, +} vtfpp_distance_mapping_flags_e; + +VTFPP_API sourcepp_buffer_t vtfpp_distance_mapping_alpha_to_distance(const unsigned char* buffer, size_t bufferLen, vtfpp_image_format_e inFormat, vtfpp_image_format_e outFormat, uint16_t width, uint16_t height, uint16_t reduceX, uint16_t reduceY, int srgb); // REQUIRES MANUAL FREE: sourcepp_buffer_free +VTFPP_API sourcepp_buffer_t vtfpp_distance_mapping_alpha_to_distance_ex(const unsigned char* buffer, size_t bufferLen, vtfpp_image_format_e inFormat, vtfpp_image_format_e outFormat, uint16_t width, uint16_t height, uint16_t reduceX, uint16_t reduceY, int srgb, float distanceSpread, float alphaThreshold, vtfpp_distance_mapping_flags_e flags, vtfpp_distance_mapping_dither_e dither, vtfpp_image_conversion_resize_filter_e filter, vtfpp_image_conversion_resize_edge_e edge, int* valveQuirks); // REQUIRES MANUAL FREE: sourcepp_buffer_free + +// C++ conversion routines +#ifdef __cplusplus + +#include + +namespace sourceppc::convert { + +inline vtfpp::DistanceMapping::Dither cast(vtfpp_distance_mapping_dither_e value) { + switch (value) { + case VTFPP_DISTANCE_MAPPING_DITHER_NONE: return vtfpp::DistanceMapping::Dither::NONE; + case VTFPP_DISTANCE_MAPPING_DITHER_GRADIENT_TANGENT: return vtfpp::DistanceMapping::Dither::GRADIENT_TANGENT; + } + return vtfpp::DistanceMapping::Dither::NONE; +} + +inline vtfpp_distance_mapping_dither_e cast(vtfpp::DistanceMapping::Dither value) { + switch (value) { + case vtfpp::DistanceMapping::Dither::NONE: return VTFPP_DISTANCE_MAPPING_DITHER_NONE; + case vtfpp::DistanceMapping::Dither::GRADIENT_TANGENT: return VTFPP_DISTANCE_MAPPING_DITHER_GRADIENT_TANGENT; + } + return VTFPP_DISTANCE_MAPPING_DITHER_NONE; +} + +inline vtfpp::DistanceMapping::Flags cast(vtfpp_distance_mapping_flags_e flags) { + return static_cast(flags); +} + +inline vtfpp_distance_mapping_flags_e cast(vtfpp::DistanceMapping::Flags flags) { + return static_cast(flags); +} + +} // namespace sourceppc::convert + +#endif diff --git a/lang/c/include/vtfppc/vtfpp.h b/lang/c/include/vtfppc/vtfpp.h index c00e8ae9..7628f99f 100644 --- a/lang/c/include/vtfppc/vtfpp.h +++ b/lang/c/include/vtfppc/vtfpp.h @@ -5,6 +5,7 @@ * include it the same way as any of the other SourcePP libraries. */ +#include "DistanceMapping.h" #include "HOT.h" #include "ImageConversion.h" #include "ImageFormats.h" diff --git a/lang/c/src/gameppc/_gameppc.cmake b/lang/c/src/gameppc/_gameppc.cmake index 51a27a44..a660701f 100644 --- a/lang/c/src/gameppc/_gameppc.cmake +++ b/lang/c/src/gameppc/_gameppc.cmake @@ -1,4 +1,5 @@ add_pretty_parser(gamepp C SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/gameppc/API.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/gameppc/gamepp.h" "${CMAKE_CURRENT_LIST_DIR}/gamepp.cpp") diff --git a/lang/c/src/steamppc/_steamppc.cmake b/lang/c/src/steamppc/_steamppc.cmake index 5529bae0..d6e0e2d7 100644 --- a/lang/c/src/steamppc/_steamppc.cmake +++ b/lang/c/src/steamppc/_steamppc.cmake @@ -1,4 +1,5 @@ add_pretty_parser(steampp C SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/steamppc/API.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/steamppc/steampp.h" "${CMAKE_CURRENT_LIST_DIR}/steampp.cpp") diff --git a/lang/c/src/vcryptppc/_vcryptppc.cmake b/lang/c/src/vcryptppc/_vcryptppc.cmake index da353e0e..151408c6 100644 --- a/lang/c/src/vcryptppc/_vcryptppc.cmake +++ b/lang/c/src/vcryptppc/_vcryptppc.cmake @@ -1,5 +1,6 @@ add_pretty_parser(vcryptpp C SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vcryptppc/API.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vcryptppc/vcryptpp.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vcryptppc/VFONT.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vcryptppc/VICE.h" diff --git a/lang/c/src/vpkppc/_vpkppc.cmake b/lang/c/src/vpkppc/_vpkppc.cmake index 76b2edf4..5de8c538 100644 --- a/lang/c/src/vpkppc/_vpkppc.cmake +++ b/lang/c/src/vpkppc/_vpkppc.cmake @@ -18,6 +18,7 @@ add_pretty_parser(vpkpp C "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vpkppc/format/WAD3.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vpkppc/format/XZP.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vpkppc/format/ZIP.h" + "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vpkppc/API.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vpkppc/Attribute.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vpkppc/Entry.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vpkppc/Options.h" diff --git a/lang/c/src/vtfppc/DistanceMapping.cpp b/lang/c/src/vtfppc/DistanceMapping.cpp new file mode 100644 index 00000000..d7759c0c --- /dev/null +++ b/lang/c/src/vtfppc/DistanceMapping.cpp @@ -0,0 +1,26 @@ +#include + +#include + +using namespace sourceppc; +using namespace vtfpp; + +VTFPP_API sourcepp_buffer_t vtfpp_distance_mapping_alpha_to_distance(const unsigned char* buffer, size_t bufferLen, vtfpp_image_format_e inFormat, vtfpp_image_format_e outFormat, uint16_t width, uint16_t height, uint16_t reduceX, uint16_t reduceY, int srgb) { + SOURCEPP_EARLY_RETURN_VAL(buffer, SOURCEPP_BUFFER_INVALID); + SOURCEPP_EARLY_RETURN_VAL(bufferLen, SOURCEPP_BUFFER_INVALID); + + return convert::toBuffer(DistanceMapping::alphaToDistance({reinterpret_cast(buffer), bufferLen}, convert::cast(inFormat), convert::cast(outFormat), width, height, reduceX, reduceY, srgb)); +} + +VTFPP_API sourcepp_buffer_t vtfpp_distance_mapping_alpha_to_distance_ex(const unsigned char* buffer, size_t bufferLen, vtfpp_image_format_e inFormat, vtfpp_image_format_e outFormat, uint16_t width, uint16_t height, uint16_t reduceX, uint16_t reduceY, int srgb, float distanceSpread, float alphaThreshold, vtfpp_distance_mapping_flags_e flags, vtfpp_distance_mapping_dither_e dither, vtfpp_image_conversion_resize_filter_e filter, vtfpp_image_conversion_resize_edge_e edge, int* valveQuirks) { + SOURCEPP_EARLY_RETURN_VAL(buffer, SOURCEPP_BUFFER_INVALID); + SOURCEPP_EARLY_RETURN_VAL(bufferLen, SOURCEPP_BUFFER_INVALID); + + if (!valveQuirks) { + return convert::toBuffer(DistanceMapping::alphaToDistance({reinterpret_cast(buffer), bufferLen}, convert::cast(inFormat), convert::cast(outFormat), width, height, reduceX, reduceY, srgb, distanceSpread, alphaThreshold, convert::cast(flags), convert::cast(dither), convert::cast(filter), convert::cast(edge), nullptr)); + } + bool valveQuirksBool = *valveQuirks; + const auto out = convert::toBuffer(DistanceMapping::alphaToDistance({reinterpret_cast(buffer), bufferLen}, convert::cast(inFormat), convert::cast(outFormat), width, height, reduceX, reduceY, srgb, distanceSpread, alphaThreshold, convert::cast(flags), convert::cast(dither), convert::cast(filter), convert::cast(edge), &valveQuirksBool)); + *valveQuirks = valveQuirksBool; + return out; +} diff --git a/lang/c/src/vtfppc/_vtfppc.cmake b/lang/c/src/vtfppc/_vtfppc.cmake index 84139d05..09546f54 100644 --- a/lang/c/src/vtfppc/_vtfppc.cmake +++ b/lang/c/src/vtfppc/_vtfppc.cmake @@ -1,5 +1,7 @@ add_pretty_parser(vtfpp C PRECOMPILED_HEADERS + "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vtfppc/API.h" + "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vtfppc/DistanceMapping.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vtfppc/HOT.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vtfppc/ImageConversion.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vtfppc/ImageFormats.h" @@ -12,6 +14,7 @@ add_pretty_parser(vtfpp C "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vtfppc/VTF.h" "${CMAKE_CURRENT_SOURCE_DIR}/lang/c/include/vtfppc/vtfpp.h" SOURCES + "${CMAKE_CURRENT_LIST_DIR}/DistanceMapping.cpp" "${CMAKE_CURRENT_LIST_DIR}/HOT.cpp" "${CMAKE_CURRENT_LIST_DIR}/ImageConversion.cpp" "${CMAKE_CURRENT_LIST_DIR}/ImageFormats.cpp" From ce3b0096bcae5d1619102c18a22ebbde339ec1dc Mon Sep 17 00:00:00 2001 From: craftablescience Date: Sun, 17 May 2026 22:27:23 -0700 Subject: [PATCH 7/8] python: distance mapping bindings --- lang/python/src/vtfpp.h | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lang/python/src/vtfpp.h b/lang/python/src/vtfpp.h index 2e7cd3ec..eb374475 100644 --- a/lang/python/src/vtfpp.h +++ b/lang/python/src/vtfpp.h @@ -321,6 +321,26 @@ inline void register_python(py::module_& m) { }, "image_data"_a, "format"_a, "width"_a, "height"_a); } + { + using namespace DistanceMapping; + auto DistanceMapping = vtfpp.def_submodule("DistanceMapping"); + + py::enum_(DistanceMapping, "Dither") + .value("NONE", Dither::NONE) + .value("GRADIENT_TANGENT", Dither::GRADIENT_TANGENT); + + py::enum_(DistanceMapping, "Flags", py::is_flag()) + .value("NONE", Flags::NONE) + .value("DISTANCEAA", Flags::DISTANCEAA) + .value("EUCLIDEAN", Flags::EUCLIDEAN) + .value("SAMPLECENTERED", Flags::SAMPLECENTERED); + + DistanceMapping.def("alpha_to_distance", [](const py::bytes& imageData, ImageFormat inFormat, ImageFormat outFormat, uint16_t width, uint16_t height, uint16_t reduceX, uint16_t reduceY, bool srgb, float distanceSpread = 1.f, float alphaThreshold = 0.04f, Flags flags = Flags::NONE, Dither dither = Dither::NONE, ImageConversion::ResizeFilter filter = ImageConversion::ResizeFilter::NICE, ImageConversion::ResizeEdge edge = ImageConversion::ResizeEdge::CLAMP, bool enableValveQuirks = false) { + const auto d = alphaToDistance({static_cast(imageData.data()), static_cast(imageData.data()) + imageData.size()}, inFormat, outFormat, width, height, reduceX, reduceY, srgb, distanceSpread, alphaThreshold, flags, dither, filter, edge, enableValveQuirks ? &enableValveQuirks : nullptr); + return std::pair{py::bytes{d.data(), d.size()}, enableValveQuirks}; + }, "image_data"_a, "in_format"_a, "out_format"_a, "width"_a, "height"_a, "reduce_x"_a, "reduce_y"_a, "srgb"_a, "distance_spread"_a = 1.f, "alpha_threshold"_a = 0.04f, "flags"_a = Flags::NONE, "dither"_a = Dither::NONE, "filter"_a = ImageConversion::ResizeFilter::NICE, "edge"_a = ImageConversion::ResizeEdge::CLAMP, "enable_valve_quirks"_a = false); + } + { using namespace ImageQuantize; auto ImageQuantize = vtfpp.def_submodule("ImageQuantize"); From 539320ee3feb37f482a53f91472f1c7e89f95895 Mon Sep 17 00:00:00 2001 From: craftablescience Date: Sun, 17 May 2026 22:53:14 -0700 Subject: [PATCH 8/8] csharp: distance mapping bindings --- lang/csharp/src/vtfpp/DLL.cs | 6 +++++ lang/csharp/src/vtfpp/DistanceMapping.cs | 32 ++++++++++++++++++++++++ lang/csharp/test/vpkpp/PackFileTest.cs | 2 +- 3 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 lang/csharp/src/vtfpp/DistanceMapping.cs diff --git a/lang/csharp/src/vtfpp/DLL.cs b/lang/csharp/src/vtfpp/DLL.cs index c662d6e8..1225a6d1 100644 --- a/lang/csharp/src/vtfpp/DLL.cs +++ b/lang/csharp/src/vtfpp/DLL.cs @@ -7,6 +7,12 @@ internal static partial class DLL { private const string Name = "sourcepp_vtfppc"; + [LibraryImport(Name)] + public static partial sourcepp.DLL.Buffer vtfpp_distance_mapping_alpha_to_distance(ReadOnlySpan buffer, ulong bufferLen, ImageFormat inFormat, ImageFormat outFormat, ushort width, ushort height, ushort reduceX, ushort reduceY, int srgb); + + [LibraryImport(Name)] + public static partial sourcepp.DLL.Buffer vtfpp_distance_mapping_alpha_to_distance_ex(ReadOnlySpan buffer, ulong bufferLen, ImageFormat inFormat, ImageFormat outFormat, ushort width, ushort height, ushort reduceX, ushort reduceY, int srgb, float distanceSpread, float alphaThreshold, DistanceMapping.Flags flags, DistanceMapping.Dither dither, ImageConversion.ResizeFilter filter, ImageConversion.ResizeEdge edge, nint valveQuirks); + [LibraryImport(Name)] public static partial nint vtfpp_hot_create(); diff --git a/lang/csharp/src/vtfpp/DistanceMapping.cs b/lang/csharp/src/vtfpp/DistanceMapping.cs new file mode 100644 index 00000000..2e6f039a --- /dev/null +++ b/lang/csharp/src/vtfpp/DistanceMapping.cs @@ -0,0 +1,32 @@ +using System; + +namespace sourcepp.vtfpp; + +public static class DistanceMapping +{ + public enum Dither + { + NONE = 0, + GRADIENT_TANGENT, + } + + public enum Flags : uint + { + NONE = 0, + DISTANCEAA = 1 << 0, + EUCLIDEAN = 1 << 1, + SAMPLECENTERED = 1 << 2, + } + + public static byte[] AlphaToDistance(ReadOnlySpan buffer, ImageFormat inFormat, ImageFormat outFormat, ushort width, ushort height, ushort resizeX, ushort resizeY, bool srgb, ref bool valveQuirks, float distanceSpread = 1.0f, float alphaThreshold = 0.04f, Flags flags = Flags.NONE, Dither dither = Dither.NONE, ImageConversion.ResizeFilter filter = ImageConversion.ResizeFilter.NICE, ImageConversion.ResizeEdge edge = ImageConversion.ResizeEdge.CLAMP) + { + var valveQuirksInt = Convert.ToInt32(valveQuirks); + unsafe + { + var valveQuirksIntPtr = &valveQuirksInt; + var output = new sourcepp.Buffer(DLL.vtfpp_distance_mapping_alpha_to_distance_ex(buffer, (ulong)buffer.Length, inFormat, outFormat, width, height, resizeX, resizeY, Convert.ToInt32(srgb), distanceSpread, alphaThreshold, flags, dither, filter, edge, valveQuirks ? (nint)valveQuirksIntPtr : 0)).Read(); + valveQuirks = Convert.ToBoolean(valveQuirksInt); + return output; + } + } +} diff --git a/lang/csharp/test/vpkpp/PackFileTest.cs b/lang/csharp/test/vpkpp/PackFileTest.cs index 380ad3fa..143daa4c 100644 --- a/lang/csharp/test/vpkpp/PackFileTest.cs +++ b/lang/csharp/test/vpkpp/PackFileTest.cs @@ -25,7 +25,7 @@ public void Open() Assert.IsFalse(vpk.IsReadOnly); - Assert.AreEqual(3509u, vpk.EntryCount()); + Assert.AreEqual(3524u, vpk.EntryCount()); Assert.AreEqual(BasePortalPath + "portal_pak_dir.vpk", vpk.Filepath); Assert.AreEqual(BasePortalPath + "portal_pak", vpk.TruncatedFilepath);