| 1 | // tTexture.cpp  |
| 2 | //  |
| 3 | // A tTexture is a 'hardware-ready' format. tTextures contain functionality for creating mipmap layers in a variety of  |
| 4 | // block-compressed and uncompressed formats. A tTexture stores each mipmap layer in a tLayer. A tTexture can be  |
| 5 | // created from either a tPicture or a dds file. The purpose of a dds file is so that content-creators have control  |
| 6 | // over the authoring of each mipmap level and the exact pixel format used. Basically if you've created a dds file,  |
| 7 | // you're saying you want the final hardware to use the image data unchanged and as authored -- same mip levels, same  |
| 8 | // pixel format, same dimensions. For this reason, dds files should not be loaded into tPictures where image  |
| 9 | // manipulation occurs and possibly lossy block-compressed dds images would be decompressed. A dds file may contain more  |
| 10 | // than one image if it is a cubemap, but a tTexture only ever represents a single image. The tTexture dds constructor  |
| 11 | // allows you to decide which one gets loaded. tTextures can save and load to a tChunk-based format, and are therefore  |
| 12 | // useful at both pipeline and for runtime loading. To save to a tChunk file format a tTexture will call the Save  |
| 13 | // method of all the tLayers.  |
| 14 | //  |
| 15 | // Copyright (c) 2006, 2016, 2017, 2020, 2023, 2024 Tristan Grimmer.  |
| 16 | // Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby  |
| 17 | // granted, provided that the above copyright notice and this permission notice appear in all copies.  |
| 18 | //  |
| 19 | // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL  |
| 20 | // IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,  |
| 21 | // INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN  |
| 22 | // AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR  |
| 23 | // PERFORMANCE OF THIS SOFTWARE.  |
| 24 |   |
| 25 | #include <Image/tTexture.h>  |
| 26 | #define RGBCX_IMPLEMENTATION  |
| 27 | #include <BC7Enc/rgbcx.h>  |
| 28 | namespace tImage  |
| 29 | {  |
| 30 |   |
| 31 |   |
| 32 | bool tTexture::BC7EncInitialized = false;  |
| 33 |   |
| 34 |   |
| 35 | bool tTexture::Set(tList<tLayer>& layers)  |
| 36 | {  |
| 37 | Clear();  |
| 38 | if (layers.GetNumItems() == 0)  |
| 39 | return false;  |
| 40 |   |
| 41 | while (!layers.IsEmpty())  |
| 42 | Layers.Append(item: layers.Remove());  |
| 43 |   |
| 44 | Opaque = Layers.First()->IsOpaqueFormat();  |
| 45 | return true;  |
| 46 | }  |
| 47 |   |
| 48 |   |
| 49 | bool tTexture::Load(const tString& ddsFile, tFaceIndex face, bool correctRowOrder)  |
| 50 | {  |
| 51 | Clear();  |
| 52 | if ((tSystem::tGetFileType(file: ddsFile) != tSystem::tFileType::DDS) || !tSystem::tFileExists(file: ddsFile))  |
| 53 | return false;  |
| 54 | tImageDDS::LoadParams params;  |
| 55 | params.Flags = tImageDDS::LoadFlag_ReverseRowOrder;  |
| 56 | tImageDDS dds(ddsFile, params);  |
| 57 | if (!dds.IsValid())  |
| 58 | return false;  |
| 59 |   |
| 60 | return Set(ddsObject&: dds, face);  |
| 61 | }  |
| 62 |   |
| 63 |   |
| 64 | bool tTexture::Set(tImageDDS& dds, tFaceIndex face)  |
| 65 | {  |
| 66 | Clear();  |
| 67 | if (!dds.IsValid())  |
| 68 | return false;  |
| 69 |   |
| 70 | if (!dds.IsCubemap())  |
| 71 | {  |
| 72 | dds.StealLayers(Layers);  |
| 73 | }  |
| 74 | else  |
| 75 | {  |
| 76 | tList<tLayer> layerSets[tFaceIndex_NumFaces];  |
| 77 | dds.StealCubemapLayers(layers: layerSets);  |
| 78 | while (!layerSets[face].IsEmpty())  |
| 79 | Layers.Append(item: layerSets[face].Remove());  |
| 80 | }  |
| 81 |   |
| 82 | if (Layers.GetNumItems() == 0)  |
| 83 | return false;  |
| 84 |   |
| 85 | Opaque = Layers.First()->IsOpaqueFormat();  |
| 86 |   |
| 87 | tLayer* mainLayer = Layers.First();  |
| 88 | int width = mainLayer->Width;  |
| 89 | int height = mainLayer->Height;  |
| 90 |   |
| 91 | if  |
| 92 | (  |
| 93 | !tMath::tInRange(val: width, min: tLayer::MinLayerDimension, max: tLayer::MaxLayerDimension) ||  |
| 94 | !tMath::tInRange(val: height, min: tLayer::MinLayerDimension, max: tLayer::MaxLayerDimension)  |
| 95 | )  |
| 96 | {  |
| 97 | Clear();  |
| 98 | return false;  |
| 99 | }  |
| 100 |   |
| 101 | return true;  |
| 102 | }  |
| 103 |   |
| 104 |   |
| 105 | /*  |
| 106 | bool tTexture::Load(const tString& imageFile, bool generateMipMaps, tPixelFormat format, tQuality quality, int forceWidth, int forceHeight)  |
| 107 | {  |
| 108 | Clear();  |
| 109 | tPicture image(imageFile);  |
| 110 | if (!image.IsValid())  |
| 111 | return false;  |
| 112 |   |
| 113 | return Set(image, generateMipMaps, format, quality, forceWidth, forceHeight);  |
| 114 | }  |
| 115 | */  |
| 116 |   |
| 117 |   |
| 118 | bool tTexture::Set(tPicture& image, bool generateMipmaps, tPixelFormat pixelFormat, tQuality quality, int forceWidth, int forceHeight)  |
| 119 | {  |
| 120 | Clear();  |
| 121 |   |
| 122 | // Sanity check force arguments.  |
| 123 | if (forceWidth && !tMath::tIsPower2(v: forceWidth))  |
| 124 | throw tError("Texture forceWidth was specified but is not a power of 2." );  |
| 125 |   |
| 126 | if (forceHeight && !tMath::tIsPower2(v: forceHeight))  |
| 127 | throw tError("Texture forceHeight was specified but is not a power of 2." );  |
| 128 |   |
| 129 | // If the dimensions are incorrect we choose the closest power of 2 to resample to. Eg. If the value is 54 we can  |
| 130 | // choose from 32 and 64, but since 32 is 22 away and 64 is only 10, we choose 64.  |
| 131 | int origWidth = image.GetWidth();  |
| 132 | int newWidth = forceWidth ? forceWidth : tMath::tClosestPower2(v: origWidth);  |
| 133 | tMath::tiClamp(val&: newWidth, min: tLayer::MinLayerDimension, max: tLayer::MaxLayerDimension);  |
| 134 |   |
| 135 | int origHeight = image.GetHeight();  |
| 136 | int newHeight = forceHeight ? forceHeight : tMath::tClosestPower2(v: origHeight);  |
| 137 | tMath::tiClamp(val&: newHeight, min: tLayer::MinLayerDimension, max: tLayer::MaxLayerDimension);  |
| 138 |   |
| 139 | if ((origWidth != newWidth) || (origHeight != newHeight))  |
| 140 | {  |
| 141 | // Might want to let user know that we're resampling here. This resize happens when the artist didn't submit  |
| 142 | // proper power-of-2-sized images or if dimensions were forced.  |
| 143 | bool ok = image.Resize(width: newWidth, height: newHeight, filter: DetermineFilter(quality));  |
| 144 | if (!ok)  |
| 145 | throw tError("Problem resampling texture '%s' to %dx%d." , tSystem::tGetFileBaseName(file: image.Filename).Pod(), newWidth, newHeight);  |
| 146 | }  |
| 147 |   |
| 148 | // This must be set before AutoDeterminePixelFormat is called.  |
| 149 | Opaque = image.IsOpaque();  |
| 150 |   |
| 151 | // Are we supposed to automatically determine the pixel format?  |
| 152 | if (pixelFormat == tPixelFormat::Auto)  |
| 153 | pixelFormat = DeterminePixelFormat(image);  |
| 154 |   |
| 155 | switch (pixelFormat)  |
| 156 | {  |
| 157 | case tPixelFormat::R8G8B8:  |
| 158 | case tPixelFormat::R8G8B8A8:  |
| 159 | ProcessImageTo_R8G8B8_Or_R8G8B8A8(image, pixelFormat, generateMipmaps, quality);  |
| 160 | break;  |
| 161 |   |
| 162 | case tPixelFormat::G3B5R5G3:  |
| 163 | ProcessImageTo_G3B5R5G3(image, generateMipmaps, quality);  |
| 164 | break;  |
| 165 |   |
| 166 | case tPixelFormat::BC1DXT1A:  |
| 167 | case tPixelFormat::BC1DXT1:  |
| 168 | case tPixelFormat::BC2DXT2DXT3:  |
| 169 | case tPixelFormat::BC3DXT4DXT5:  |
| 170 | ProcessImageTo_BCTC(image, pixelFormat, generateMipmaps, quality);  |
| 171 | break;  |
| 172 |   |
| 173 | default:  |
| 174 | throw tError("Conversion of image to pixel format %d failed." , int(pixelFormat));  |
| 175 | }  |
| 176 |   |
| 177 | // Since the convert functions may or may not modify the source tPicture image, we guarantee invalidness here.  |
| 178 | image.Clear();  |
| 179 | return true;  |
| 180 | }  |
| 181 |   |
| 182 |   |
| 183 | void tTexture::ProcessImageTo_R8G8B8_Or_R8G8B8A8(tPicture& image, tPixelFormat format, bool generateMipmaps, tQuality quality)  |
| 184 | {  |
| 185 | tAssert((format == tPixelFormat::R8G8B8) || (format == tPixelFormat::R8G8B8A8));  |
| 186 | int width = image.GetWidth();  |
| 187 | int height = image.GetHeight();  |
| 188 | int bytesPerPixel = (format == tPixelFormat::R8G8B8) ? 3 : 4;  |
| 189 | tResampleFilter filter = DetermineFilter(quality);  |
| 190 |   |
| 191 | // This loop resamples (reduces) the image multiple times for mipmap generation. In general we should start with  |
| 192 | // the original image every time so that we're not applying interpolations to interpolations (better quality).  |
| 193 | // However, since we are only using a box-filter (pixel averaging) there is no benefit to having a fresh src  |
| 194 | // image each time. The math is equivalent: (a+b/2 + c+d/2)/2 = (a+b+c+d)/4. For now we are saving the extra  |
| 195 | // effort to start with an original every time. If we ever use a more advanced filter we'll need to change this  |
| 196 | // behaviour. Note: we're now using bilinear as the lower quality filter. Should probably make the change.  |
| 197 | while (1)  |
| 198 | {  |
| 199 | int numDataBytes = width*height*bytesPerPixel;  |
| 200 | uint8* layerData = new uint8[numDataBytes];  |
| 201 |   |
| 202 | // We can just extract the data out directly from RGBA to either RGB or RGBA.  |
| 203 | uint8* srcPixel = (uint8*)image.GetPixelPointer();  |
| 204 | uint8* dstPixel = layerData;  |
| 205 | for (int p = 0; p < width*height; p++)  |
| 206 | {  |
| 207 | tStd::tMemcpy(dest: dstPixel, src: srcPixel, numBytes: bytesPerPixel);  |
| 208 | srcPixel += 4; // Src is always RGBA.  |
| 209 | dstPixel += bytesPerPixel; // Dst is RGB or RGBA.  |
| 210 | }  |
| 211 |   |
| 212 | tLayer* layer = new tLayer(format, width, height, layerData, true);  |
| 213 | tAssert(numDataBytes == layer->GetDataSize());  |
| 214 | Layers.Append(item: layer);  |
| 215 |   |
| 216 | // Was this the last one?  |
| 217 | if (((width == 1) && (height == 1)) || !generateMipmaps)  |
| 218 | break;  |
| 219 |   |
| 220 | // Remember, width and height are not necessarily the same. As soon as one reaches 1 it needs to stay there until  |
| 221 | // the other gets there too.  |
| 222 | if (width != 1)  |
| 223 | width >>= 1;  |
| 224 |   |
| 225 | if (height != 1)  |
| 226 | height >>= 1;  |
| 227 |   |
| 228 | image.Resize(width, height, filter);  |
| 229 | }  |
| 230 | }  |
| 231 |   |
| 232 |   |
| 233 | void tTexture::ProcessImageTo_G3B5R5G3(tPicture& image, bool generateMipmaps, tQuality quality)  |
| 234 | {  |
| 235 | int width = image.GetWidth();  |
| 236 | int height = image.GetHeight();  |
| 237 | int bytesPerPixel = 2;  |
| 238 | tResampleFilter filter = DetermineFilter(quality);  |
| 239 |   |
| 240 | // This loop resamples (reduces) the image multiple times for mipmap generation. In general we should start with  |
| 241 | // the original image every time so that we're not applying interpolations to interpolations (better quality).  |
| 242 | // However, since we are only using a box-filter (pixel averaging) there is no benefit to having a fresh src  |
| 243 | // image each time. The math is equivalent: (a+b/2 + c+d/2)/2 = (a+b+c+d)/4. For now we are saving the extra  |
| 244 | // effort to start with an original every time. If we ever use a more advanced filter we'll need to change this  |
| 245 | // behaviour. Note: we're now using bilinear as the lower quality filter. Should probably make the change.  |
| 246 | while (1)  |
| 247 | {  |
| 248 | int numDataBytes = width*height*bytesPerPixel;  |
| 249 | uint8* layerData = new uint8[numDataBytes];  |
| 250 |   |
| 251 | // We need to change the src data (RGBA) into 16bits.  |
| 252 | tPixel4b* srcPixel = image.GetPixelPointer();  |
| 253 | uint8* dstPixel = layerData;  |
| 254 | for (int p = 0; p < width*height; p++)  |
| 255 | {  |
| 256 | // In memory. Each letter a bit: GGGBBBBB RRRRRGGG  |
| 257 | dstPixel[0] = (srcPixel->G & 0x1C << 3) | (srcPixel->B >> 3);  |
| 258 | dstPixel[1] = (srcPixel->R & 0xF8) | (srcPixel->G >> 5);  |
| 259 | srcPixel++;  |
| 260 | dstPixel += bytesPerPixel;  |
| 261 | }  |
| 262 |   |
| 263 | tLayer* layer = new tLayer(tPixelFormat::G3B5R5G3, width, height, layerData, true);  |
| 264 | tAssert(numDataBytes == layer->GetDataSize());  |
| 265 | Layers.Append(item: layer);  |
| 266 |   |
| 267 | // Was this the last one?  |
| 268 | if (((width == 1) && (height == 1)) || !generateMipmaps)  |
| 269 | break;  |
| 270 |   |
| 271 | // Remember, width and height are not necessarily the same. As soon as one reaches 1 it needs to stay there until  |
| 272 | // the other gets there too.  |
| 273 | if (width != 1)  |
| 274 | width >>= 1;  |
| 275 |   |
| 276 | if (height != 1)  |
| 277 | height >>= 1;  |
| 278 |   |
| 279 | image.Resize(width, height, filter);  |
| 280 | }  |
| 281 | }  |
| 282 |   |
| 283 |   |
| 284 | void tTexture::ProcessImageTo_BCTC(tPicture& image, tPixelFormat pixelFormat, bool generateMipmaps, tQuality quality)  |
| 285 | {  |
| 286 | int width = image.GetWidth();  |
| 287 | int height = image.GetHeight();  |
| 288 | tResampleFilter filter = DetermineFilter(quality);  |
| 289 | if (!tMath::tIsPower2(v: width) || !tMath::tIsPower2(v: height))  |
| 290 | throw tError("Texture must be power-of-2 to be compressed to a BC format." );  |
| 291 |   |
| 292 | if (!BC7EncInitialized)  |
| 293 | {  |
| 294 | rgbcx::init(mode: rgbcx::bc1_approx_mode::cBC1Ideal);  |
| 295 | BC7EncInitialized = true;  |
| 296 | }  |
| 297 |   |
| 298 | // This loop resamples (reduces) the image multiple times for mipmap generation. In general we should start with  |
| 299 | // the original image every time so that we're not applying interpolations to interpolations (better quality).  |
| 300 | // However, since we are only using a box-filter (pixel averaging) there is no benefit to having a fresh src  |
| 301 | // image each time. The math is equivalent: (a+b/2 + c+d/2)/2 = (a+b+c+d)/4. For now we are saving the extra  |
| 302 | // effort to start with an original every time. If we ever use a more advanced filter we'll need to change this  |
| 303 | // behaviour. Note: we're now using bilinear as the lower quality filter. Should probably make the change.  |
| 304 | while (1)  |
| 305 | {  |
| 306 | // Setup the layer data to receive the compressed data.  |
| 307 | int numBlocks = tMath::tMax(a: 1, b: width/4) * tMath::tMax(a: 1, b: height/4);  |
| 308 | int blockSize = (pixelFormat == tPixelFormat::BC1DXT1) ? 8 : 16;  |
| 309 | int outputSize = numBlocks * blockSize;  |
| 310 | uint8* outputData = new uint8[outputSize];  |
| 311 |   |
| 312 | int encoderQualityLevel = DetermineBlockEncodeQualityLevel(quality);  |
| 313 | bool allow3colour = true;  |
| 314 | bool useTransparentTexelsForBlack = false;  |
| 315 |   |
| 316 | uint8* blockDest = outputData;  |
| 317 | uint8* pixelSrc = (uint8*)image.GetPixelPointer();  |
| 318 | for (int block = 0; block < numBlocks; block++)  |
| 319 | {  |
| 320 | switch (pixelFormat)  |
| 321 | {  |
| 322 | case tPixelFormat::BC1DXT1:  |
| 323 | rgbcx::encode_bc1(level: encoderQualityLevel, pDst: blockDest, pPixels: pixelSrc, allow_3color: allow3colour, allow_transparent_texels_for_black: useTransparentTexelsForBlack);  |
| 324 | break;  |
| 325 |   |
| 326 | case tPixelFormat::BC3DXT4DXT5:  |
| 327 | rgbcx::encode_bc3(level: encoderQualityLevel, pDst: blockDest, pPixels: pixelSrc);  |
| 328 | break;  |
| 329 |   |
| 330 | default:  |
| 331 | throw tError("Unsupported BC pixel format %d." , int(pixelFormat));  |
| 332 | }  |
| 333 | blockDest += blockSize;  |
| 334 | pixelSrc += sizeof(tPixel4b);  |
| 335 | }  |
| 336 |   |
| 337 | // The last true in this call allows the layer constructor to steal the outputData pointer. Avoids extra memcpys.  |
| 338 | tLayer* layer = new tLayer(pixelFormat, width, height, outputData, true);  |
| 339 | tAssert(layer->GetDataSize() == outputSize);  |
| 340 | Layers.Append(item: layer);  |
| 341 |   |
| 342 | // Was this the last one?  |
| 343 | if (((width == 1) && (height == 1)) || !generateMipmaps)  |
| 344 | break;  |
| 345 |   |
| 346 | if (width != 1)  |
| 347 | width >>= 1;  |
| 348 |   |
| 349 | if (height != 1)  |
| 350 | height >>= 1;  |
| 351 |   |
| 352 | // When using BC compression we don't ever want to scale lower than 4x4 as that is the individual block size.  |
| 353 | // we need at least that much data so the compressor can do it's job. Consider a 128x4 texture: Ideally we want  |
| 354 | // that to rescale to 64x4, rather than 64x2. So it's reasonable to just stop once either dimension reaches 4  |
| 355 | // because otherwise non-uniform scale issues come into play. In short, we either have to deal with this  |
| 356 | // distortion, or the cropping issue of just stopping. We do the latter because it's just easier.  |
| 357 | //  |
| 358 | // Just because we stop downscaling doesn't mean that we don't generate all the mipmap levels! We still  |
| 359 | // generate all the way to 1x1. It's only the src data that stops being down-sampled.  |
| 360 | if ((image.GetWidth() >= 8) && (image.GetHeight() >= 8))  |
| 361 | {  |
| 362 | // This code scales by half using the correct quality filter.  |
| 363 | int newWidth = image.GetWidth() / 2;  |
| 364 | int newHeight = image.GetHeight() / 2;  |
| 365 | image.Resize(width: newWidth, height: newHeight, filter);  |
| 366 | }  |
| 367 | }  |
| 368 | }  |
| 369 |   |
| 370 |   |
| 371 | int tTexture::ComputeMaxNumberOfMipmaps() const  |
| 372 | {  |
| 373 | if (!IsValid())  |
| 374 | return 0;  |
| 375 |   |
| 376 | int maxDim = tMath::tMax(a: GetWidth(), b: GetHeight());  |
| 377 | int count = 0;  |
| 378 | while (maxDim > 0)  |
| 379 | {  |
| 380 | maxDim >>= 1;  |
| 381 | count++;  |
| 382 | }  |
| 383 |   |
| 384 | return count;  |
| 385 | }  |
| 386 |   |
| 387 |   |
| 388 | void tTexture::Save(tChunkWriter& chunk) const  |
| 389 | {  |
| 390 | chunk.Begin(chunkID: tChunkID::Image_Texture);  |
| 391 | {  |
| 392 | chunk.Begin(chunkID: tChunkID::Image_TextureProperties);  |
| 393 | {  |
| 394 | chunk.Write(b: Opaque);  |
| 395 | }  |
| 396 | chunk.End();  |
| 397 |   |
| 398 | chunk.Begin(chunkID: tChunkID::Image_TextureLayers);  |
| 399 | {  |
| 400 | for (tLayer* layer = Layers.First(); layer; layer = layer->Next())  |
| 401 | layer->Save(chunk);  |
| 402 | }  |
| 403 | chunk.End();  |
| 404 | }  |
| 405 | chunk.End();  |
| 406 | }  |
| 407 |   |
| 408 |   |
| 409 | void tTexture::Load(const tChunk& chunk)  |
| 410 | {  |
| 411 | Clear();  |
| 412 | if (chunk.ID() != tChunkID::Image_Texture)  |
| 413 | return;  |
| 414 |   |
| 415 | int numLayers = 0;  |
| 416 | for (tChunk ch = chunk.First(); ch.IsValid(); ch = ch.Next())  |
| 417 | {  |
| 418 | switch (ch.ID())  |
| 419 | {  |
| 420 | case tChunkID::Image_TextureProperties:  |
| 421 | {  |
| 422 | ch.GetItem(item&: Opaque);  |
| 423 | break;  |
| 424 | }  |
| 425 |   |
| 426 | case tChunkID::Image_TextureLayers:  |
| 427 | {  |
| 428 | for (tChunk layerChunk = ch.First(); layerChunk.IsValid(); layerChunk = layerChunk.Next())  |
| 429 | Layers.Append(item: new tLayer(layerChunk));  |
| 430 | break;  |
| 431 | }  |
| 432 | }  |
| 433 | }  |
| 434 | }  |
| 435 |   |
| 436 |   |
| 437 | bool tTexture::operator==(const tTexture& src) const  |
| 438 | {  |
| 439 | if (!IsValid() || !src.IsValid())  |
| 440 | return false;  |
| 441 |   |
| 442 | if (Opaque != src.Opaque)  |
| 443 | return false;  |
| 444 |   |
| 445 | if (Layers.GetNumItems() != Layers.GetNumItems())  |
| 446 | return false;  |
| 447 |   |
| 448 | tLayer* srcLayer = Layers.First();  |
| 449 | for (tLayer* layer = Layers.First(); layer; layer = layer->Next(), srcLayer = srcLayer->Next())  |
| 450 | if (*layer != *srcLayer)  |
| 451 | return false;  |
| 452 |   |
| 453 | return true;  |
| 454 | }  |
| 455 |   |
| 456 |   |
| 457 | }  |
| 458 | |