io/texture.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | // SPDX-License-Identifier: BSD-3-Clause | ||
| 2 | /// @file texture.h | ||
| 3 | /// @brief Stored textures: formats, DDS and KTX2 reading, BC decoding, baking images into mipped, | ||
| 4 | /// block-compressed DDS files, and a disk cache of baked textures. | ||
| 5 | #pragma once | ||
| 6 | |||
| 7 | #include "ernie/export.h" | ||
| 8 | |||
| 9 | #include "ernie/io/image.h" | ||
| 10 | #include "ernie/io/raster.h" | ||
| 11 | #include "ernie/util/file.h" | ||
| 12 | |||
| 13 | #include "ernie/util/glm.h" | ||
| 14 | |||
| 15 | #include <cstddef> | ||
| 16 | #include <cstdint> | ||
| 17 | #include <filesystem> | ||
| 18 | #include <memory> | ||
| 19 | #include <optional> | ||
| 20 | #include <span> | ||
| 21 | #include <string_view> | ||
| 22 | #include <vector> | ||
| 23 | |||
| 24 | namespace ernie { | ||
| 25 | |||
| 26 | // ---------------------------------------------------------------------------------------------- | ||
| 27 | // Formats | ||
| 28 | |||
| 29 | /// @brief A stored texture's texel layout. | ||
| 30 | /// Plain texels, or BC1-BC7 blocks of 4 x 4 uploaded as they are. | ||
| 31 | enum class TextureFormat : std::uint32_t { | ||
| 32 | R8, | ||
| 33 | Rg8, | ||
| 34 | Rgba8, | ||
| 35 | Rgba8Srgb, | ||
| 36 | Bgra8, | ||
| 37 | Bgra8Srgb, | ||
| 38 | Rgba16F, | ||
| 39 | Rgba32F, | ||
| 40 | /// With one-bit alpha (DXT1). | ||
| 41 | Bc1, | ||
| 42 | Bc1Srgb, | ||
| 43 | Bc2, | ||
| 44 | Bc2Srgb, | ||
| 45 | Bc3, | ||
| 46 | Bc3Srgb, | ||
| 47 | Bc4, | ||
| 48 | Bc4Snorm, | ||
| 49 | Bc5, | ||
| 50 | Bc5Snorm, | ||
| 51 | Bc6hUfloat, | ||
| 52 | Bc6hSfloat, | ||
| 53 | Bc7, | ||
| 54 | Bc7Srgb, | ||
| 55 | }; | ||
| 56 | |||
| 57 | /// @brief What a `TextureFormat` stores. | ||
| 58 | struct TextureFormatInfo { | ||
| 59 | /// Texels along a block's side; 1 when uncompressed. | ||
| 60 | std::uint32_t blockSize = 1; | ||
| 61 | /// Bytes per block (or texel). | ||
| 62 | std::uint32_t blockBytes = 4; | ||
| 63 | /// Channels it decodes to. | ||
| 64 | std::uint32_t channels = 4; | ||
| 65 | /// Whether colour decodes from sRGB. | ||
| 66 | bool srgb = false; | ||
| 67 | /// @return Whether the format is block-compressed. | ||
| 68 | 455 | bool compressed() const { | |
| 69 | 455 | return blockSize > 1; | |
| 70 | } | ||
| 71 | }; | ||
| 72 | |||
| 73 | /// @brief Describes a texture format. | ||
| 74 | /// @param format The format. | ||
| 75 | /// @return Its block size, bytes, channels and sRGB flag. | ||
| 76 | ERNIE_API TextureFormatInfo FormatInfo(TextureFormat format); | ||
| 77 | |||
| 78 | /// @brief Switches sRGB decoding on or off where a format has both variants (RGBA8, BGRA8, BC1, | ||
| 79 | /// BC2, BC3, BC7). | ||
| 80 | /// @param format The format. | ||
| 81 | /// @param srgb Whether to decode from sRGB. | ||
| 82 | /// @return The variant, or `format` unchanged. | ||
| 83 | ERNIE_API TextureFormat WithSrgb(TextureFormat format, bool srgb); | ||
| 84 | |||
| 85 | /// @brief Bytes of an image in whole blocks. | ||
| 86 | /// @param format The format. | ||
| 87 | /// @param width Width, in texels. | ||
| 88 | /// @param height Height, in texels. | ||
| 89 | /// @return The size in bytes. | ||
| 90 | ERNIE_API std::size_t ImageBytes(TextureFormat format, std::uint32_t width, std::uint32_t height); | ||
| 91 | |||
| 92 | /// @brief Mips of a full chain, down to 1 x 1. | ||
| 93 | /// @param width Width of mip 0, in texels. | ||
| 94 | /// @param height Height of mip 0, in texels. | ||
| 95 | /// @return The number of mips. | ||
| 96 | ERNIE_API std::uint32_t MipCount(std::uint32_t width, std::uint32_t height); | ||
| 97 | |||
| 98 | /// @brief A side of a mip. | ||
| 99 | /// @param side The side at mip 0, in texels. | ||
| 100 | /// @param mip The mip. | ||
| 101 | /// @return The side halved `mip` times, at least 1. | ||
| 102 | 3203 | constexpr std::uint32_t MipSide(std::uint32_t side, std::uint32_t mip) { | |
| 103 | 3113 | return side >> mip > 0 ? side >> mip : 1u; | |
| 104 | } | ||
| 105 | |||
| 106 | // ---------------------------------------------------------------------------------------------- | ||
| 107 | // Stored textures | ||
| 108 | |||
| 109 | /// @brief A texture as a DDS or KTX2 file stores it, not decoded. | ||
| 110 | /// Each layer holds `mips` levels, halving down from full size; a cube map's six faces are layers. | ||
| 111 | struct ERNIE_API TextureData { | ||
| 112 | /// The texel format. | ||
| 113 | TextureFormat format = TextureFormat::Rgba8; | ||
| 114 | /// Width of mip 0, in texels. | ||
| 115 | std::uint32_t width = 0; | ||
| 116 | /// Height of mip 0, in texels. | ||
| 117 | std::uint32_t height = 0; | ||
| 118 | /// Array layers. | ||
| 119 | std::uint32_t layers = 1; | ||
| 120 | /// Mips per layer. | ||
| 121 | std::uint32_t mips = 1; | ||
| 122 | /// Whether the layers are cube faces. | ||
| 123 | bool cube = false; | ||
| 124 | /// The owned texels; empty when read in place from `mapping`. | ||
| 125 | std::vector<std::byte> bytes; | ||
| 126 | /// The mapped file holding the texels; null when in `bytes`. | ||
| 127 | std::shared_ptr<const MappedFile> mapping; | ||
| 128 | /// The texels within `mapping`. | ||
| 129 | std::span<const std::byte> mapped; | ||
| 130 | /// Where each mip starts in `Data()`, at `layer * mips + mip`. | ||
| 131 | std::vector<std::size_t> offsets; | ||
| 132 | |||
| 133 | /// @return The texels: the mapped file's, else `bytes`. | ||
| 134 | 240 | std::span<const std::byte> Data() const { | |
| 135 | 221 | return mapping != nullptr ? mapped : std::span<const std::byte>(bytes); | |
| 136 | } | ||
| 137 | |||
| 138 | /// @brief One mip of one layer. | ||
| 139 | /// @param layer The layer. | ||
| 140 | /// @param mip The mip. | ||
| 141 | /// @return Its bytes. | ||
| 142 | /// @throws std::out_of_range If the layer or mip does not exist. | ||
| 143 | std::span<const std::byte> Mip(std::uint32_t layer, std::uint32_t mip) const; | ||
| 144 | }; | ||
| 145 | |||
| 146 | /// @brief Tests for a .dds or .ktx2 extension, in any case. | ||
| 147 | /// @param path The file. | ||
| 148 | /// @return Whether `LoadTextureFile` reads it. | ||
| 149 | ERNIE_API bool IsTextureFile(const std::filesystem::path& path); | ||
| 150 | |||
| 151 | /// @brief Reads a DDS or KTX2 file as stored, by extension. | ||
| 152 | /// The file is mapped and read in place, so texels load from disk only as used; supercompressed | ||
| 153 | /// KTX2 levels decode into memory. DDS: DXGI formats, legacy DXTn/ATIn/BC4/BC5 codes and 32-bit | ||
| 154 | /// RGBA or BGRA; 2D, arrays and cubes, not volumes. KTX2: the same formats, stored or zstd; Basis | ||
| 155 | /// ETC1S and UASTC transcode to BC5 for two channels (`toktx --normal_mode`), BC6H for HDR, else | ||
| 156 | /// BC7. | ||
| 157 | /// @param path The file. | ||
| 158 | /// @return The texture. | ||
| 159 | /// @throws std::runtime_error If the file cannot be read, is malformed or truncated, or has an | ||
| 160 | /// unsupported format. | ||
| 161 | /// | ||
| 162 | /// References: | ||
| 163 | /// - Microsoft, "DDS programming guide" (DDS_HEADER, DDS_HEADER_DXT10). | ||
| 164 | /// https://learn.microsoft.com/en-us/windows/win32/direct3ddds/dx-graphics-dds-pguide | ||
| 165 | /// - Khronos, "KTX File Format Specification", version 2.0. | ||
| 166 | /// https://registry.khronos.org/KTX/specs/2.0/ktxspec.v2.html | ||
| 167 | /// - Binomial, "Basis Universal" (the transcoder). https://github.com/BinomialLLC/basis_universal | ||
| 168 | ERNIE_API TextureData LoadTextureFile(const std::filesystem::path& path); | ||
| 169 | |||
| 170 | /// @brief Parses a DDS file's contents (`LoadTextureFile`). | ||
| 171 | /// @param file The bytes. | ||
| 172 | /// @return The texture, owning its texels. | ||
| 173 | ERNIE_API TextureData ReadDds(std::span<const std::byte> file); | ||
| 174 | |||
| 175 | /// @brief Parses a KTX2 file's contents (`LoadTextureFile`). | ||
| 176 | /// @param file The bytes. | ||
| 177 | /// @return The texture, owning its texels. | ||
| 178 | ERNIE_API TextureData ReadKtx2(std::span<const std::byte> file); | ||
| 179 | |||
| 180 | /// @brief Writes a DDS file with the DX10 header (`ReadDds` reads it back). | ||
| 181 | /// @param path The file. | ||
| 182 | /// @param texture Every layer's mips. | ||
| 183 | /// @throws std::runtime_error If the file cannot be written. | ||
| 184 | ERNIE_API void WriteDdsFile(const std::filesystem::path& path, const TextureData& texture); | ||
| 185 | |||
| 186 | /// @brief Decodes BC blocks for devices that cannot sample them. | ||
| 187 | /// BC1/BC3/BC7 become RGBA8 (sRGB kept), BC4 R8 and BC5 RG8, every layer and mip. | ||
| 188 | /// @param texture The texture. | ||
| 189 | /// @return The decoded texture; uncompressed ones unchanged. | ||
| 190 | /// @throws std::invalid_argument For BC2, BC6H and signed BC4/BC5. | ||
| 191 | /// | ||
| 192 | /// References: | ||
| 193 | /// - R. Geldreich, "bc7enc_rdo" (rgbcx's BC1 to BC5 decoders, bc7decomp's BC7), MIT or public | ||
| 194 | /// domain. https://github.com/richgel999/bc7enc_rdo | ||
| 195 | ERNIE_API TextureData DecodeBlocks(const TextureData& texture); | ||
| 196 | |||
| 197 | // ---------------------------------------------------------------------------------------------- | ||
| 198 | // Baking | ||
| 199 | |||
| 200 | /// @brief What a texture holds, deciding how it is mipped and stored. | ||
| 201 | enum class TextureRole { | ||
| 202 | /// Colour, sRGB unless said otherwise; BC7. | ||
| 203 | Colour, | ||
| 204 | /// Tangent-space normals as n / 2 + 1/2, mipped as `MipFilter::NormalSlopes`, stored as x and | ||
| 205 | /// y (BC5); shaders rebuild z. | ||
| 206 | Normal, | ||
| 207 | /// Linear values such as roughness or metallic; BC7. | ||
| 208 | Data, | ||
| 209 | }; | ||
| 210 | |||
| 211 | /// @brief Encodes RGBA8 images into BC blocks off the CPU. | ||
| 212 | /// Bakes use it for the formats it handles (`GpuBlockEncoder`), the CPU encoders for the rest. | ||
| 213 | class ERNIE_API BlockEncoder { | ||
| 214 | public: | ||
| 215 | virtual ~BlockEncoder() = default; | ||
| 216 | |||
| 217 | /// @param format A block format. | ||
| 218 | /// @return Whether this encoder handles it. | ||
| 219 | virtual bool Encodes(TextureFormat format) const = 0; | ||
| 220 | |||
| 221 | /// @brief Encodes an image; edges repeat the last row and column. | ||
| 222 | /// @param rgba RGBA8 texels, rows top to bottom. | ||
| 223 | /// @param width Width, in texels. | ||
| 224 | /// @param height Height, in texels. | ||
| 225 | /// @param format The block format. | ||
| 226 | /// @param out `ImageBytes(format, width, height)` bytes. | ||
| 227 | virtual void Encode(std::span<const std::uint8_t> rgba, std::uint32_t width, | ||
| 228 | std::uint32_t height, TextureFormat format, std::byte* out) = 0; | ||
| 229 | }; | ||
| 230 | |||
| 231 | /// @brief How `BakeTexture` stores a texture. | ||
| 232 | struct BakeOptions { | ||
| 233 | /// What the texture holds. | ||
| 234 | TextureRole role = TextureRole::Colour; | ||
| 235 | /// The stored format; the role's by default. BC1, BC3, BC4, BC5, BC7 and RGBA8 are written; | ||
| 236 | /// BC6H is not. | ||
| 237 | std::optional<TextureFormat> format; | ||
| 238 | /// Whether colour is stored as sRGB rather than linear. | ||
| 239 | bool srgb = true; | ||
| 240 | /// BC7 effort on the CPU, 0 (fastest) to 4. | ||
| 241 | int quality = 1; | ||
| 242 | /// An encoder used where it can, such as on the GPU; null for the CPU's alone. | ||
| 243 | BlockEncoder* encoder = nullptr; | ||
| 244 | }; | ||
| 245 | |||
| 246 | /// @brief Encodes a mip chain into a stored format. | ||
| 247 | /// BC blocks past a mip's edge repeat its last row and column. | ||
| 248 | /// @param mips Levels as `MipChain` makes them, full size first. | ||
| 249 | /// @param format The stored format. | ||
| 250 | /// @param encoding How 8-bit values are encoded. | ||
| 251 | /// @param quality BC7 effort, 0 to 4. | ||
| 252 | /// @param encoder Used where it handles `format`; else all CPU threads. | ||
| 253 | /// @return The texture. | ||
| 254 | /// @throws std::invalid_argument For an unsupported format, or mips that do not halve. | ||
| 255 | ERNIE_API TextureData EncodeTexture(std::span<const Image> mips, TextureFormat format, | ||
| 256 | ImageEncoding encoding, int quality = 1, | ||
| 257 | BlockEncoder* encoder = nullptr); | ||
| 258 | |||
| 259 | /// @brief Reads an 8-bit PNG or JPEG's size from its header. | ||
| 260 | /// @param file The image. | ||
| 261 | /// @return Width and height; none for other or unreadable files. | ||
| 262 | ERNIE_API std::optional<glm::uvec2> Rgba8ImageSize(const std::filesystem::path& file); | ||
| 263 | |||
| 264 | /// @brief Decodes an 8-bit PNG or JPEG and mips it in 8 bits. | ||
| 265 | /// For images too large for floats (a 21600 x 10800 Earth is 3.7 GB as floats, 1.2 GB as RGBA8 | ||
| 266 | /// mips). Each mip is the 2 x 2 average of the last. | ||
| 267 | /// @param file The image. | ||
| 268 | /// @param srgb Whether rgb is averaged as linear light. | ||
| 269 | /// @return RGBA8 levels down to 1 x 1, full size first. | ||
| 270 | /// @throws std::runtime_error If it cannot be read or is not an 8-bit PNG or JPEG. | ||
| 271 | ERNIE_API std::vector<std::vector<std::uint8_t>> LoadRgba8Mips(const std::filesystem::path& file, | ||
| 272 | bool srgb); | ||
| 273 | |||
| 274 | /// @brief Bakes an image into a mipped DDS texture. | ||
| 275 | /// Mips are filtered by role, then compressed: what virtual textures need, at a quarter to an | ||
| 276 | /// eighth of RGBA8's memory. 8-bit PNG and JPEG bake in 8 bits; normals, EXR and 16-bit go | ||
| 277 | /// through floats. TIFF, PDS3 and `.mosaic` bake in bands (`RasterBake`) and return mapped from | ||
| 278 | /// `output`. | ||
| 279 | /// @param input The source image. | ||
| 280 | /// @param output The DDS file to write. | ||
| 281 | /// @param options How to store it. | ||
| 282 | /// @return The stored texture. | ||
| 283 | /// @throws std::runtime_error If the input cannot be read or the output written. | ||
| 284 | /// @throws std::invalid_argument As `EncodeTexture`. | ||
| 285 | /// | ||
| 286 | /// References: | ||
| 287 | /// - R. Geldreich, "bc7enc_rdo" (bc7enc and rgbcx). https://github.com/richgel999/bc7enc_rdo | ||
| 288 | ERNIE_API TextureData BakeTexture(const std::filesystem::path& input, | ||
| 289 | const std::filesystem::path& output, | ||
| 290 | const BakeOptions& options = {}); | ||
| 291 | |||
| 292 | /// @brief Bakes a colour or data raster into DDS a band at a time. | ||
| 293 | /// For images larger than memory, JPEG's limit or one file, and to spread a bake over frames. A | ||
| 294 | /// worker reads 1024-row bands and mips them exactly; `Step` encodes ready work on the caller's | ||
| 295 | /// thread. At most about 256 MB of band mips wait. Samples are 8- or 16-bit (high byte kept); 1 | ||
| 296 | /// channel is grey, 2 grey and alpha, 3 RGB. | ||
| 297 | class ERNIE_API RasterBake { | ||
| 298 | public: | ||
| 299 | /// @brief Starts a bake and writes the file's header. | ||
| 300 | /// @param source The raster. | ||
| 301 | /// @param output The DDS file. | ||
| 302 | /// @param options How to store it. | ||
| 303 | /// @throws std::invalid_argument For normal maps or samples not 8- or 16-bit. | ||
| 304 | /// @throws std::runtime_error If `output` cannot be written. | ||
| 305 | RasterBake(std::unique_ptr<GridSource> source, const std::filesystem::path& output, | ||
| 306 | const BakeOptions& options = {}); | ||
| 307 | /// @brief Stops the bake; an unfinished file is removed. | ||
| 308 | ~RasterBake(); | ||
| 309 | RasterBake(const RasterBake&) = delete; | ||
| 310 | RasterBake& operator=(const RasterBake&) = delete; | ||
| 311 | |||
| 312 | /// @return The stored texture's layout; texels are in the file. | ||
| 313 | const TextureData& Layout() const; | ||
| 314 | |||
| 315 | /// @brief Encodes and writes what the reader has ready. | ||
| 316 | /// @param texels About how many texels to encode; at least a band's row of blocks. | ||
| 317 | /// @param wait Whether to wait for the reader. | ||
| 318 | /// @return True once the file is complete. | ||
| 319 | /// @throws std::exception Whatever reading or writing threw. | ||
| 320 | bool Step(std::uint64_t texels, bool wait = false); | ||
| 321 | |||
| 322 | /// @brief Encodes everything left, waiting for the reader. | ||
| 323 | /// @return The stored texture's layout. | ||
| 324 | const TextureData& Finish(); | ||
| 325 | |||
| 326 | /// @return Texels encoded so far, over every mip. | ||
| 327 | std::uint64_t Encoded() const; | ||
| 328 | /// @return Texels to encode in all. | ||
| 329 | std::uint64_t Total() const; | ||
| 330 | |||
| 331 | private: | ||
| 332 | struct State; | ||
| 333 | std::unique_ptr<State> state_; | ||
| 334 | }; | ||
| 335 | |||
| 336 | /// @brief Bakes a mosaic of 8-bit PNG or JPEG tiles, as `BakeTexture`. | ||
| 337 | /// Each tile's top mips are encoded in turn; smaller mips come from the tiles' last mips joined. | ||
| 338 | /// At most two tiles are held at once. Normal maps are not supported. | ||
| 339 | /// @param tiles Tiles of one size, row by row, top to bottom. | ||
| 340 | /// @param columns Tiles per row. | ||
| 341 | /// @param output The DDS file. | ||
| 342 | /// @param options How to store it. | ||
| 343 | /// @return The stored layout; texels are in the file. | ||
| 344 | /// @throws std::invalid_argument For tiles not filling whole rows or of different sizes, or normal | ||
| 345 | /// maps. | ||
| 346 | /// @throws std::runtime_error If a tile cannot be read or the output written. | ||
| 347 | ERNIE_API TextureData BakeMosaic(std::span<const std::filesystem::path> tiles, | ||
| 348 | std::uint32_t columns, const std::filesystem::path& output, | ||
| 349 | const BakeOptions& options = {}); | ||
| 350 | |||
| 351 | // ---------------------------------------------------------------------------------------------- | ||
| 352 | // Cache | ||
| 353 | |||
| 354 | /// @brief Keeps prepared textures between runs as DDS files. | ||
| 355 | /// A texture is decoded, mipped and encoded once until its file or read settings change. Least | ||
| 356 | /// recently used entries go past the capacity. Any thread may read; I/O failures count as misses. | ||
| 357 | 4 | class ERNIE_API TextureCache { | |
| 358 | public: | ||
| 359 | /// @brief The default folder, from `ERNIE_TEXTURE_CACHE` (empty or "0" for none) or | ||
| 360 | /// `<user cache>/ernie/textures`. | ||
| 361 | /// @return The folder, or nothing when disabled. | ||
| 362 | static std::optional<std::filesystem::path> DefaultDirectory(); | ||
| 363 | |||
| 364 | /// @brief Opens a cache. | ||
| 365 | /// @param directory The folder; none disables the cache. | ||
| 366 | /// @param capacity The size limit in bytes; 16 GB by default (a global Blue Marble mosaic is | ||
| 367 | /// 5 GB of BC7). | ||
| 368 | explicit TextureCache(std::optional<std::filesystem::path> directory = DefaultDirectory(), | ||
| 369 | std::uintmax_t capacity = std::uintmax_t{16} << 30); | ||
| 370 | |||
| 371 | /// @return Whether the cache has a folder. | ||
| 372 | 2 | bool Enabled() const { | |
| 373 | 2 | return directory_.has_value(); | |
| 374 | } | ||
| 375 | |||
| 376 | /// @brief Reads a cached texture and marks it as just used. | ||
| 377 | /// @param key The entry's key. | ||
| 378 | /// @return The texture, mapped as by `LoadTextureFile`; none if absent or unreadable. | ||
| 379 | std::optional<TextureData> Read(std::string_view key) const; | ||
| 380 | |||
| 381 | /// @brief Caches a texture atomically, then prunes. | ||
| 382 | /// @param key The entry's key. | ||
| 383 | /// @param texture The texture. | ||
| 384 | void Write(std::string_view key, const TextureData& texture) const; | ||
| 385 | |||
| 386 | /// @brief A new file for a DDS written in parts (`RasterBake`), then cached by `Publish`. | ||
| 387 | /// @param key The entry's key. | ||
| 388 | /// @return The path, or none without a cache. | ||
| 389 | std::optional<std::filesystem::path> PartialPath(std::string_view key) const; | ||
| 390 | |||
| 391 | /// @brief Moves a complete DDS file into the cache, then prunes others. | ||
| 392 | /// @param partial The file from `PartialPath`. | ||
| 393 | /// @param key The entry's key. | ||
| 394 | /// @return False if it could not be moved; it is then removed. | ||
| 395 | bool Publish(const std::filesystem::path& partial, std::string_view key) const; | ||
| 396 | |||
| 397 | /// @brief Forgets a cached texture, if any. | ||
| 398 | /// @param key The entry's key. | ||
| 399 | void Remove(std::string_view key) const; | ||
| 400 | |||
| 401 | private: | ||
| 402 | std::filesystem::path EntryPath(std::string_view key) const; | ||
| 403 | /// Removes the least recently used past the capacity, but not `kept`. | ||
| 404 | void Prune(const std::filesystem::path& kept) const; | ||
| 405 | |||
| 406 | /// The folder, if enabled. | ||
| 407 | std::optional<std::filesystem::path> directory_; | ||
| 408 | /// The size limit, in bytes. | ||
| 409 | std::uintmax_t capacity_; | ||
| 410 | }; | ||
| 411 | |||
| 412 | } // namespace ernie | ||
| 413 |