Files
dusklight/mods/basic_cosmetics_mod/src/texture_utils.cpp
T
2026-08-14 23:49:56 -07:00

672 lines
24 KiB
C++

#include "texture_utils.hpp"
#include "color_utils.hpp"
#include "mod.hpp"
#include "mods/svc/config.h"
#include "mods/svc/log.hpp"
// Forward declaration needed for J3DModelLoader to be happy
class J3DVertexData;
#include "JSystem/J3DGraphLoader/J3DModelLoader.h"
#include "JSystem/JSupport/JSupport.h"
#include "JSystem/JUtility/JUTNameTab.h"
#include "d/actor/d_a_alink.h"
static void get_gx_tile_info(uint8_t format, uint32_t& tileWidth, uint32_t& tileHeight, uint32_t& tileSize) {
switch (format) {
case GX_TF_I8:
case GX_TF_IA4:
case GX_TF_C8:
tileWidth = 8; tileHeight = 4; tileSize = 32;
break;
case GX_TF_IA8:
case GX_TF_RGB565:
case GX_TF_RGB5A3:
case GX_TF_C14X2:
tileWidth = 4; tileHeight = 4; tileSize = 32;
break;
case GX_TF_RGBA8:
tileWidth = 4; tileHeight = 4; tileSize = 64;
break;
case GX_TF_I4:
case GX_TF_C4:
case GX_TF_CMPR:
default:
tileWidth = 8; tileHeight = 8; tileSize = 32;
break;
}
}
uint32_t get_image_data_size(uint32_t format, uint32_t width, uint32_t height, uint32_t mipmapCount) {
uint32_t tileWidth, tileHeight, tileSize;
get_gx_tile_info(format, tileWidth, tileHeight, tileSize);
uint32_t totalSize = 0;
for (uint8_t i = 0; i < mipmapCount; ++i) {
// Round dimensions up to nearest tile boundary
uint32_t paddedWidth = (width + tileWidth - 1) & ~(tileWidth - 1);
uint32_t paddedHeight = (height + tileHeight - 1) & ~(tileHeight - 1);
uint32_t tilesX = paddedWidth / tileWidth;
uint32_t tilesY = paddedHeight / tileHeight;
totalSize += tilesX * tilesY * tileSize;
// Downscale dimensions for next mipmap level
width = std::max(1u, width >> 1);
height = std::max(1u, height >> 1);
}
return totalSize;
}
// When left is greater than right
// 0b00 points to the left color
// 0b01 points to the right color
// 0b10 is closer to left color
// 0b11 is closer to right color
// When left is not greater than right
// 0b00 points to the left color
// 0b01 points to the right color
// 0b10 is midway between the colors
// 0b11 is transparent
// That means when maintaining the relative order, if we have to swap the colors:
// in the case of left being greater than right:
// 0b00 will swap to 0b01
// 0b01 will swap to 0b00
// 0b10 will swap to 0b11
// 0b11 will swap to 0b10
// So the left bit stays the same, and the right bit changes
// Can do xor (^) like 0b01010101 or 0x55 for each u16
// in the case of left not being greater than right:
// 0b00 will swap to 0b01
// 0b01 will swap to 0b00
// 0b10 will stay the same
// 0b11 will stay the same
// so if the left bit is a 0, the right bit will change
uint32_t swap_index_bits(bool leftIsGreater, uint32_t bits) {
if (leftIsGreater) {
return bits ^ 0x55555555;
}
const uint32_t mask = ((bits >> 1) & 0x55555555) ^ 0x55555555;
return bits ^ mask;
}
void recolor_cmpr_texture(const TextureReplacementData& replacementData, const GXColor color, std::vector<u8>& newTextureDataOut)
{
uint16_t recolors[0x100];
for (int32_t i = 0; i < 0x100; i++) {
recolors[i] = blend_overlay_rgb_565(i, color);
}
const uint8_t mipCount = (replacementData.data.mip_count > 0) ? replacementData.data.mip_count : 1;
uint32_t mipWidth = replacementData.key.width;
uint32_t mipHeight = replacementData.key.height;
uint8_t* currentAddr = newTextureDataOut.data();
for (uint8_t mip = 0; mip < mipCount; ++mip) {
// Round dimensions up to the nearest 8x8 tile boundary
const uint32_t roundedWidth = (mipWidth + 7) & ~7;
const uint32_t roundedHeight = (mipHeight + 7) & ~7;
const uint32_t numBlocks = (roundedWidth / 8) * (roundedHeight / 8);
const uint32_t iterations = numBlocks * 4; // 4 CMPR sub-blocks per 8x8 tile
for (uint32_t i = 0; i < iterations; i++) {
auto* rgb565Ptr = reinterpret_cast<BE<uint16_t>*>(currentAddr);
auto leftRgb565 = rgb565Ptr[0];
auto rightRgb565 = rgb565Ptr[1];
const bool leftIsGreater = leftRgb565 > rightRgb565;
const uint32_t leftGrayVal = desaturate_rgb_565(leftRgb565);
const uint32_t rightGrayVal = desaturate_rgb_565(rightRgb565);
uint16_t leftNewRgb565 = recolors[leftGrayVal];
uint16_t rightNewRgb565 = recolors[rightGrayVal];
bool needsBitSwap = false;
if (leftIsGreater) {
if (leftNewRgb565 == rightNewRgb565) {
// Need to make sure that subtracting 1 does not mess
// everything up. For example, 0x1000 - 1 => 0x0fff which is
// a completely different color.
if ((leftNewRgb565 & 0x1f) == 0) {
// If left value has 0 blue, we change its blue to 1.
leftNewRgb565 += 1;
}
rightNewRgb565 = leftNewRgb565 - 1;
}
else if (leftNewRgb565 < rightNewRgb565) {
needsBitSwap = true;
}
}
else if (leftNewRgb565 > rightNewRgb565) {
needsBitSwap = true;
}
if (needsBitSwap) {
// The left and right colors are swapping so that their values
// are relative in the same way. We need to update the bits
// referencing the palette entries to handle the swap.
const uint16_t temp = leftNewRgb565;
leftNewRgb565 = rightNewRgb565;
rightNewRgb565 = temp;
auto wordPtr = reinterpret_cast<BE<uint32_t>*>(currentAddr);
const uint32_t bits = wordPtr[1];
const uint32_t newBits = swap_index_bits(leftIsGreater, bits);
wordPtr[1] = newBits;
}
rgb565Ptr[0] = leftNewRgb565;
rgb565Ptr[1] = rightNewRgb565;
currentAddr += 8;
}
// Halve dimensions for the next mipmap level
mipWidth = std::max(1u, mipWidth >> 1);
mipHeight = std::max(1u, mipHeight >> 1);
}
}
void recolor_rgb5a3_texture(const TextureReplacementData& replacementData, const GXColor color, std::vector<u8>& newTextureDataOut)
{
// Precompute lookup tables for both RGB555 (opaque) and RGB444 (translucent) modes
uint16_t recolors_rgb555[0x100];
uint16_t recolors_rgb444[0x100];
for (int32_t i = 0; i < 0x100; i++) {
const uint8_t r = blend_overlay_channel(i, color.r);
const uint8_t g = blend_overlay_channel(i, color.g);
const uint8_t b = blend_overlay_channel(i, color.b);
// Pack as RGB555: Bit 15 set to 1 + 5 bits R, G, B
recolors_rgb555[i] = 0x8000 | ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
// Pack as RGB444: 4 bits R, G, B (Bit 15 remains 0)
recolors_rgb444[i] = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
}
constexpr int32_t blockWidth = 4;
constexpr int32_t blockHeight = 4;
const int32_t roundedWidth = replacementData.key.width + ((blockWidth - (replacementData.key.width % blockWidth)) % blockWidth);
const int32_t roundedHeight = replacementData.key.height + ((blockHeight - (replacementData.key.height % blockHeight)) % blockHeight);
const int32_t totalPixels = roundedWidth * roundedHeight;
auto* pixelPtr = newTextureDataOut.data();
for (int32_t i = 0; i < totalPixels; i++) {
const uint16_t rawPixel = pixelPtr[i];
// MSB determines if pixel is opaque or translucent
if (rawPixel & 0x8000) {
// Pixel is opaque
const uint8_t r5 = (rawPixel >> 10) & 0x1F;
const uint8_t g5 = (rawPixel >> 5) & 0x1F;
const uint8_t b5 = rawPixel & 0x1F;
// Expand 5-bit to 8-bit
const uint8_t r8 = (r5 << 3) | (r5 >> 2);
const uint8_t g8 = (g5 << 3) | (g5 >> 2);
const uint8_t b8 = (b5 << 3) | (b5 >> 2);
const uint8_t grayVal = static_cast<uint8_t>((r8 * 77 + g8 * 150 + b8 * 29) >> 8);
pixelPtr[i] = recolors_rgb555[grayVal];
} else {
// Pixel is translucent
const uint16_t alpha3 = rawPixel & 0x7000;
const uint8_t r4 = (rawPixel >> 8) & 0x0F;
const uint8_t g4 = (rawPixel >> 4) & 0x0F;
const uint8_t b4 = rawPixel & 0x0F;
// Expand 4-bit to 8-bit
const uint8_t r8 = (r4 << 4) | r4;
const uint8_t g8 = (g4 << 4) | g4;
const uint8_t b8 = (b4 << 4) | b4;
const uint8_t grayVal = static_cast<uint8_t>((r8 * 77 + g8 * 150 + b8 * 29) >> 8);
// Combine original alpha with recolored RGB444
pixelPtr[i] = alpha3 | recolors_rgb444[grayVal];
}
}
}
// Function to encode a single 4x4 sub-block (16 pixels) into an 8-byte CMPR block
static void encode_cmpr_sub_block(uint8_t* dst, const uint8_t pixels[16]) {
uint8_t min_val = 255;
uint8_t max_val = 0;
for (int i = 0; i < 16; ++i) {
if (pixels[i] < min_val) min_val = pixels[i];
if (pixels[i] > max_val) max_val = pixels[i];
}
auto intensity_to_rgb565 = [](uint8_t val) -> uint16_t {
uint16_t r5 = val >> 3;
uint16_t g6 = val >> 2;
uint16_t b5 = val >> 3;
return static_cast<uint16_t>((r5 << 11) | (g6 << 5) | b5);
};
uint16_t c0_565 = intensity_to_rgb565(max_val);
uint16_t c1_565 = intensity_to_rgb565(min_val);
uint32_t indices = 0;
if (max_val > min_val) {
// Enforce c0_565 > c1_565 in unsigned 16-bit representation to use 4-color mode
if (c0_565 == c1_565) {
if ((c0_565 & 0x001F) < 0x001F) {
c0_565 += 1;
} else {
c1_565 -= 1;
}
}
// Interpolated 8-bit intensity values for quantization
const int c0 = max_val;
const int c1 = min_val;
const int c2 = (2 * max_val + min_val) / 3;
const int c3 = (max_val + 2 * min_val) / 3;
// Map each pixel to the nearest palette entry
for (int i = 0; i < 16; ++i) {
const int p = pixels[i];
const int d0 = std::abs(p - c0);
const int d1 = std::abs(p - c1);
const int d2 = std::abs(p - c2);
const int d3 = std::abs(p - c3);
uint32_t best_idx = 0;
int min_d = d0;
if (d1 < min_d) { min_d = d1; best_idx = 1; }
if (d2 < min_d) { min_d = d2; best_idx = 2; }
if (d3 < min_d) { min_d = d3; best_idx = 3; }
indices |= (best_idx << (30 - (2 * i)));
}
}
// Account for big endian data expectation
dst[0] = static_cast<uint8_t>(c0_565 >> 8);
dst[1] = static_cast<uint8_t>(c0_565 & 0xFF);
dst[2] = static_cast<uint8_t>(c1_565 >> 8);
dst[3] = static_cast<uint8_t>(c1_565 & 0xFF);
dst[4] = static_cast<uint8_t>(indices >> 24);
dst[5] = static_cast<uint8_t>((indices >> 16) & 0xFF);
dst[6] = static_cast<uint8_t>((indices >> 8) & 0xFF);
dst[7] = static_cast<uint8_t>(indices & 0xFF);
}
bool convert_i8_to_cmpr(TextureReplacementData& replacementData, std::vector<u8>& cmprOut) {
if (cmprOut.empty() || replacementData.key.width % 8 != 0 || replacementData.key.height % 8 != 0) {
return false;
}
const uint8_t mipCount = (replacementData.data.mip_count > 0) ? replacementData.data.mip_count : 1;
uint32_t mipWidth = replacementData.key.width;
uint32_t mipHeight = replacementData.key.height;
const uint8_t* readPtr = cmprOut.data();
uint8_t* writePtr = cmprOut.data();
for (uint8_t mip = 0; mip < mipCount; ++mip) {
const uint32_t paddedWidthI8 = (mipWidth + 7) & ~7;
const uint32_t paddedHeightI8 = (mipHeight + 3) & ~3;
const uint32_t tilesX_I8 = paddedWidthI8 / 8;
const uint32_t paddedWidthCMPR = (mipWidth + 7) & ~7;
const uint32_t paddedHeightCMPR = (mipHeight + 7) & ~7;
const uint32_t blocksX_CMPR = paddedWidthCMPR / 8;
const uint32_t blocksY_CMPR = paddedHeightCMPR / 8;
for (uint32_t by = 0; by < blocksY_CMPR; ++by) {
for (uint32_t bx = 0; bx < blocksX_CMPR; ++bx) {
const uint32_t topTileIdx = (2 * by) * tilesX_I8 + bx;
const uint32_t bottomTileIdx = (2 * by + 1) * tilesX_I8 + bx;
const uint8_t* topTileData = readPtr + (topTileIdx * 32);
const uint8_t* bottomTileData = readPtr + (bottomTileIdx * 32);
uint8_t subBlockPixels[4][16];
for (int row = 0; row < 4; ++row) {
for (int col = 0; col < 4; ++col) {
subBlockPixels[0][row * 4 + col] = topTileData[row * 8 + col];
subBlockPixels[1][row * 4 + col] = topTileData[row * 8 + col + 4];
}
}
for (int row = 0; row < 4; ++row) {
for (int col = 0; col < 4; ++col) {
subBlockPixels[2][row * 4 + col] = bottomTileData[row * 8 + col];
subBlockPixels[3][row * 4 + col] = bottomTileData[row * 8 + col + 4];
}
}
// Write 32 bytes of CMPR output into the same buffer
for (const auto& subBlockPixel : subBlockPixels) {
encode_cmpr_sub_block(writePtr, subBlockPixel);
writePtr += 8;
}
}
}
const uint32_t tilesY_I8 = paddedHeightI8 / 4;
readPtr += tilesX_I8 * tilesY_I8 * 32;
mipWidth = std::max(1u, mipWidth >> 1);
mipHeight = std::max(1u, mipHeight >> 1);
}
// Resize for new CMPR image
const size_t finalSize = writePtr - cmprOut.data();
cmprOut.resize(finalSize);
// Update format
replacementData.data.gx_format = GX_TF_CMPR;
return true;
}
void recolor_texture(TextureReplacementData& replacementData, GXColor color, std::vector<u8>& newTextureDataOut) {
switch (replacementData.key.gx_format) {
case GX_TF_CMPR:
recolor_cmpr_texture(replacementData, color, newTextureDataOut);
break;
case GX_TF_RGB5A3:
recolor_rgb5a3_texture(replacementData, color, newTextureDataOut);
break;
case GX_TF_I8:
if (convert_i8_to_cmpr(replacementData, newTextureDataOut)) {
recolor_cmpr_texture(replacementData, color, newTextureDataOut);
} else {
mods::log::debug("Could not convert {} from i8 to cmpr", replacementData.textureName);
}
break;
default:
break;
}
}
std::unordered_map<ConfigVarHandle, std::list<TextureReplacementData>>& get_texture_replacements() {
static std::unordered_map<ConfigVarHandle, std::list<TextureReplacementData>> replacements{};
if (replacements.empty()) {
replacements = {
{get_cvars().herosTunicCapColor, {
{
.arc = "Kmdl",
.modelFileName = "al_head.bmd",
.textureName = "al_cap",
}
}},
{get_cvars().herosTunicTorsoColor, {
{
.arc = "Kmdl",
.modelFileName = "al.bmd",
.textureName = "al_upbody",
}
}},
{get_cvars().herosTunicSkirtColor, {
{
.arc = "Kmdl",
.modelFileName = "al.bmd",
.textureName = "al_lowbody",
}
}},
{get_cvars().zoraArmorCapColor, {
{
.arc = "Zmdl",
.modelFileName = "zl_head.bmd",
.textureName = "zl_cap",
}
}},
{get_cvars().zoraArmorHelmetColor, {
{
.arc = "Zmdl",
.modelFileName = "zl_head.bmd",
.textureName = "zl_helmet",
}
}},
{get_cvars().zoraArmorTorsoColor, {
{
.arc = "Zmdl",
.modelFileName = "zl.bmd",
.textureName = "zl_armor",
},
{
.arc = "Zmdl",
.modelFileName = "zl.bmd",
.textureName = "zl_armL",
}
}},
{get_cvars().zoraArmorScalesColor, {
{
.arc = "Zmdl",
.modelFileName = "zl.bmd",
.textureName = "zl_body",
}
}},
{get_cvars().zoraArmorFlippersColor, {
{
.arc = "Zmdl",
.modelFileName = "zl.bmd",
.textureName = "zl_boots",
}
}},
{get_cvars().woodenSwordColor, {
{
.arc = "Bmdl", // Ordon Clothes Model
.modelFileName = "al_swb.bmd",
.textureName = "al_SWB",
},
{
.arc = "Kmdl", // Hero's Tunic Model
.modelFileName = "al_swb.bmd",
.textureName = "al_SWB",
},
{
.arc = "Zmdl", // Zora Armor Model
.modelFileName = "al_swb.bmd",
.textureName = "al_SWB",
},
{
.arc = "Mmdl", // Magic Armor Model
.modelFileName = "al_swb.bmd",
.textureName = "al_SWB",
},
{
.arc = "O_gD_SWB", // Get Item Model
.modelFileName = "o_gd_al_swb.bmd",
.textureName = "al_SWB",
}
}},
{get_cvars().ordonSwordHandleColor, {
{
.arc = "Alink",
.modelFileName = "al_swa.bmd",
.textureName = "al_SWgripA",
},
{
.arc = "O_gD_SWA", // Get Item Model
.modelFileName = "o_gd_al_swa.bmd",
.textureName = "al_SWgripA",
}
}},
{get_cvars().ordonSwordBladeColor, {
{
.arc = "Alink",
.modelFileName = "al_swa.bmd",
.textureName = "al_SWA",
}
}},
{get_cvars().msHandleColor, {
{
.arc = "Alink",
.modelFileName = "al_swm.bmd",
.textureName = "al_SWgripM",
}
}},
{get_cvars().msBladeColor, {
{
.arc = "Alink",
.modelFileName = "al_swm.bmd",
.textureName = "al_SWM",
}
}},
{get_cvars().boomerangColor, {
{
.arc = "Alink", // Boomerang in Link's hand
.modelFileName = "al_boom.bmd",
.textureName = "L_al_boom00",
},
{
.arc = "E_mk", // Boomerang in Ook's hand
.modelFileName = "bm.bmd",
.textureName = "L_al_boom00",
},
{
.arc = "E_mk", // Boomerang in Ook's hand
.modelFileName = "bm.bmd",
.textureName = "bm_boom",
},
{
.arc = "O_gD_boom", // Get Item Model
.modelFileName = "o_gd_boom.bmd",
.textureName = "L_al_boom00",
}
}},
{get_cvars().ironBootsColor, {
{
.arc = "Bmdl", // Ordon Clothes Model
.modelFileName = "al_bootsh.bmd",
.textureName = "al_bootsH",
},
{
.arc = "Kmdl", // Hero's Tunic Model
.modelFileName = "al_bootsh.bmd",
.textureName = "al_bootsH",
},
{
.arc = "Zmdl", // Zora Armor Model
.modelFileName = "al_bootsh.bmd",
.textureName = "al_bootsH",
},
{
.arc = "Mmdl", // Magic Armor Model
.modelFileName = "al_bootsh.bmd",
.textureName = "al_bootsH",
},
{
.arc = "O_gD_boot", // Get Item Model
.modelFileName = "o_gd_al_bootsh.bmd",
.textureName = "al_bootsH",
}
}},
{get_cvars().spinnerColor, {
{
.arc = "Alink", // Spinner used by Link
.modelFileName = "al_sp.bmd",
.textureName = "al_SP",
},
{
.arc = "O_gD_SP", // Get Item Model
.modelFileName = "o_gd_al_sp.bmd",
.textureName = "al_SP",
}
}},
{get_cvars().linkHairColor, {
{
.arc = "Bmdl", // Ordon Clothes Model
.modelFileName = "bl_head.bmd",
.textureName = "bl_hair",
},
{
.arc = "Kmdl", // Hero's Tunic Model
.modelFileName = "al_head.bmd",
.textureName = "al_hair",
},
{
.arc = "Mmdl", // Magic Armor Model
.modelFileName = "ml_head.bmd",
.textureName = "al_hair",
}
}},
{get_cvars().wolfLinkColor, {
{
.arc = "Wmdl",
.modelFileName = "wl.bmd",
.textureName = "wl_body",
},
{
.arc = "Wmdl",
.modelFileName = "wl.bmd",
.textureName = "wl_eye.1",
},
{
.arc = "Wmdl",
.modelFileName = "wl.bmd",
.textureName = "wl_eye.2",
},
{
.arc = "Wmdl",
.modelFileName = "wl.bmd",
.textureName = "wl_eye.3",
},
{
.arc = "Wmdl",
.modelFileName = "wl.bmd",
.textureName = "wl_eye.4",
},
{
.arc = "Wmdl",
.modelFileName = "wl.bmd",
.textureName = "wl_eye.5",
}
}},
{get_cvars().eponaColor, {
{
.arc = "Horse",
.modelFileName = "hs.bmd",
.textureName = "hs_body",
},
{
.arc = "Horse",
.modelFileName = "hs.bmd",
.textureName = "hs_eye.1",
},
{
.arc = "Horse",
.modelFileName = "hs.bmd",
.textureName = "hs_eye.2",
},
{
.arc = "Horse",
.modelFileName = "hs.bmd",
.textureName = "hs_eye.3",
},
}},
};
}
return replacements;
}