Crafter.Asset/interfaces/Crafter.Asset-Texture.cppm
Jorijn van der Graaf c04b7a331a
feat(glb): a loader for binary glTF, and images decoded from memory
- Crafter.Asset:GLB. GlbAsset::Load reads a .glb from a file or a buffer:
  - nodes, with their parents;
  - every mesh's primitives: positions, normals, UV0, tangents (computed
    when absent), joints, weights (normalized), u32 indices;
  - skins with their inverse-bind matrices;
  - materials: PBR, emissive strength, transmission, ior, alpha mode;
  - images decoded to RGBA8, and samplers;
  - cameras and KHR_lights_punctual lights;
  - the raw JSON, and the extras of the scene, nodes, materials and lights.
  nlohmann/json 3.12.0 is vendored in lib/; only the implementation sees it.
- A broken file comes back as an error rather than a crash:
  - bad magic, truncation, a sparse accessor;
  - a chunk, view or accessor past its bounds, checked so that nothing
    wraps round, with 32-bit size_t too;
  - a stride outside 4..252, or one its elements overlap in;
  - a reference to something that doesn't exist, or children that form a
    cycle (found stepping through each node once, however deep);
  - a path that isn't a file;
  - an image larger than a texture's 65535 a side.
- TextureAsset::LoadPNGFromMemory decodes an image already in memory.
  LoadPNG refuses images over 65535 a side instead of truncating their size
  (in its float path, past the end of the pixels).
- Tests: GLB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:46:43 +02:00

254 lines
No EOL
10 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module;
#define STB_IMAGE_IMPLEMENTATION
#include "../lib/stb_image.h"
#define STB_IMAGE_RESIZE_IMPLEMENTATION
#include "../lib/stb_image_resize2.h"
export module Crafter.Asset:Texture;
import :Compression;
import std;
import Crafter.Math;
namespace fs = std::filesystem;
export namespace Crafter {
enum class OpaqueType : std::uint8_t {
FullyOpaque, // All pixels have A of 255
SemiOpaque, // All pixels have A of 0 or 255 (no blending needed)
Transparent // Color blending is used
};
struct TextureAssetInfo {
std::uint16_t sizeX;
std::uint16_t sizeY;
OpaqueType opaque;
};
// GDeflate-compressed counterpart of TextureAsset<T>::Save output.
// Single-region blob: the pixel array as one stream.
struct CompressedTextureAsset {
std::uint16_t sizeX = 0;
std::uint16_t sizeY = 0;
OpaqueType opaque = OpaqueType::FullyOpaque;
std::uint32_t pixelStride = 0;
Compression::CompressedBlob blob;
};
namespace TextureAssetFormat {
inline constexpr char magic[4] = {'C', 'G', 'D', 'T'};
inline constexpr std::uint32_t version = 1;
}
inline CompressedTextureAsset LoadCompressedTexture(fs::path path) {
std::ifstream file(path, std::ios::binary);
char magic[4];
file.read(magic, 4);
if (std::memcmp(magic, TextureAssetFormat::magic, 4) != 0) {
Compression::Fatal("LoadCompressedTexture: bad magic on " + path.string());
}
std::uint32_t version = 0;
file.read(reinterpret_cast<char*>(&version), sizeof(version));
if (version != TextureAssetFormat::version) {
Compression::Fatal("LoadCompressedTexture: unsupported version on " + path.string());
}
CompressedTextureAsset out;
file.read(reinterpret_cast<char*>(&out.sizeX), sizeof(out.sizeX));
file.read(reinterpret_cast<char*>(&out.sizeY), sizeof(out.sizeY));
file.read(reinterpret_cast<char*>(&out.opaque), sizeof(out.opaque));
file.read(reinterpret_cast<char*>(&out.pixelStride), sizeof(out.pixelStride));
out.blob = Compression::ReadBlob(file);
return out;
}
template <typename T>
struct TextureAsset {
std::uint16_t sizeX;
std::uint16_t sizeY;
OpaqueType opaque;
std::vector<T> pixels;
void Save(fs::path path) {
std::ofstream file(path, std::ios::binary);
file.write(reinterpret_cast<char*>(&sizeX), sizeof(sizeX));
file.write(reinterpret_cast<char*>(&sizeY), sizeof(sizeY));
file.write(reinterpret_cast<char*>(&opaque), sizeof(opaque));
file.write(reinterpret_cast<char*>(pixels.data()), pixels.size() * sizeof(T));
}
void SaveCompressed(fs::path path) const {
std::array<std::span<const std::byte>, 1> streams = {
std::as_bytes(std::span(pixels)),
};
Compression::CompressedBlob blob = Compression::CompressStreams(streams);
std::ofstream file(path, std::ios::binary);
file.write(TextureAssetFormat::magic, 4);
std::uint32_t version = TextureAssetFormat::version;
std::uint32_t stride = static_cast<std::uint32_t>(sizeof(T));
file.write(reinterpret_cast<const char*>(&version), sizeof(version));
file.write(reinterpret_cast<const char*>(&sizeX), sizeof(sizeX));
file.write(reinterpret_cast<const char*>(&sizeY), sizeof(sizeY));
file.write(reinterpret_cast<const char*>(&opaque), sizeof(opaque));
file.write(reinterpret_cast<const char*>(&stride), sizeof(stride));
Compression::WriteBlob(file, blob);
}
static TextureAsset<T> Load(fs::path path) {
TextureAsset<T> tex;
std::ifstream file(path, std::ios::binary);
file.read(reinterpret_cast<char*>(&tex.sizeX), sizeof(tex.sizeX));
file.read(reinterpret_cast<char*>(&tex.sizeY), sizeof(tex.sizeY));
file.read(reinterpret_cast<char*>(&tex.opaque), sizeof(tex.opaque));
tex.pixels.resize(tex.sizeX * tex.sizeY);
file.read(reinterpret_cast<char*>(tex.pixels.data()), tex.sizeX * tex.sizeY * sizeof(T));
return tex;
}
// Bilinear-resize the pixel buffer in-place to `newW × newH`.
// Used to normalize albedos to a uniform size before stacking them
// into a WebGPU texture_2d_array (which requires identical layer
// dimensions). stb_image_resize2 handles RGBA8 directly.
void Resize(std::uint16_t newW, std::uint16_t newH) requires (sizeof(T) == 4) {
if (sizeX == newW && sizeY == newH) return;
std::vector<T> out(static_cast<std::size_t>(newW) * newH);
stbir_resize_uint8_linear(
reinterpret_cast<const std::uint8_t*>(pixels.data()), sizeX, sizeY, 0,
reinterpret_cast<std::uint8_t*>(out.data()), newW, newH, 0,
STBIR_RGBA);
pixels = std::move(out);
sizeX = newW;
sizeY = newH;
}
static TextureAssetInfo LoadInfo(fs::path path) {
TextureAssetInfo info;
std::ifstream file(path, std::ios::binary);
file.read(reinterpret_cast<char*>(&info.sizeX), sizeof(info.sizeX));
file.read(reinterpret_cast<char*>(&info.sizeY), sizeof(info.sizeY));
file.read(reinterpret_cast<char*>(&info.opaque), sizeof(info.opaque));
return info;
}
static void Load(fs::path path, T* pixels, std::uint16_t sizeX, std::uint16_t sizeY) {
std::ifstream file(path, std::ios::binary);
file.seekg(sizeof(std::uint16_t) + sizeof(std::uint16_t) + sizeof(std::uint8_t), std::ios::cur);
file.read(reinterpret_cast<char*>(pixels), sizeX * sizeY * sizeof(T));
}
// Decodes anything stb_image reads (PNG, JPEG, ...) to 8-bit RGBA, or
// to normalized floats when TT is a floating-point component type.
template <typename TT>
static TextureAsset<T> LoadPNG(fs::path path) {
std::filesystem::path abs = std::filesystem::absolute(path);
int sizeX;
int sizeY;
unsigned char* data = stbi_load(abs.string().c_str(), &sizeX, &sizeY, nullptr, 4);
if (data == nullptr) {
Compression::Fatal(std::format("LoadPNG: {}: {}", abs.string(), stbi_failure_reason()));
}
if (!FitsSize(sizeX, sizeY)) {
stbi_image_free(data);
Compression::Fatal(std::format("LoadPNG: {}: {}x{} is more than a texture's 65535 a side", abs.string(), sizeX, sizeY));
}
return FromRGBA8<TT>(data, sizeX, sizeY);
}
// LoadPNG from an image that is already in memory, e.g. one embedded
// in a GLB. An image that doesn't decode comes back as an error value
// rather than a Fatal: the caller is reading a larger asset and can
// say which part was bad.
template <typename TT>
static std::expected<TextureAsset<T>, std::string> LoadPNGFromMemory(std::span<const std::byte> encoded) {
int sizeX;
int sizeY;
unsigned char* data = stbi_load_from_memory(reinterpret_cast<const stbi_uc*>(encoded.data()),
static_cast<int>(encoded.size()), &sizeX, &sizeY, nullptr, 4);
if (data == nullptr) {
return std::unexpected(std::string(stbi_failure_reason()));
}
if (!FitsSize(sizeX, sizeY)) {
stbi_image_free(data);
return std::unexpected(std::format("{}x{} is more than a texture's 65535 a side", sizeX, sizeY));
}
return FromRGBA8<TT>(data, sizeX, sizeY);
}
private:
// sizeX and sizeY hold 16 bits.
static bool FitsSize(int sizeX, int sizeY) {
return sizeX <= std::numeric_limits<std::uint16_t>::max() && sizeY <= std::numeric_limits<std::uint16_t>::max();
}
// Converts stb's RGBA8 buffer, classifies its opacity and frees it.
template <typename TT>
static TextureAsset<T> FromRGBA8(unsigned char* data, int sizeX, int sizeY) {
TextureAsset<T> tex;
tex.sizeX = sizeX;
tex.sizeY = sizeY;
tex.pixels.resize(tex.sizeX*tex.sizeY);
tex.opaque = OpaqueType::FullyOpaque;
if constexpr(
#ifndef __wasm__
std::same_as<TT, _Float16> ||
#endif
std::same_as<TT, float> || std::same_as<TT, double>) {
for(std::uint32_t i = 0; i < sizeX*sizeY; i++) {
tex.pixels[i].r = TT(data[i*4])/255;
tex.pixels[i].g = TT(data[i*4+1])/255;
tex.pixels[i].b = TT(data[i*4+2])/255;
tex.pixels[i].a = TT(data[i*4+3])/255;
}
for(std::uint32_t i = 0; i < tex.sizeX* tex.sizeY; i++) {
if(tex.pixels[i].a != 1) {
tex.opaque = OpaqueType::SemiOpaque;
for(std::uint32_t i2 = i; i2 < tex.sizeX* tex.sizeY; i2++) {
if(tex.pixels[i2].a != 0 && tex.pixels[i2].a != 1) {
tex.opaque = OpaqueType::Transparent;
stbi_image_free(data);
return tex;
}
}
stbi_image_free(data);
return tex;
}
}
} else {
std::memcpy(tex.pixels.data(), data, tex.sizeX * tex.sizeY * 4);
for(std::uint32_t i = 0; i < tex.sizeX* tex.sizeY; i++) {
if(tex.pixels[i].a != 255) {
tex.opaque = OpaqueType::SemiOpaque;
for(std::uint32_t i2 = i; i2 < tex.sizeX* tex.sizeY; i2++) {
if(tex.pixels[i2].a != 0 && tex.pixels[i2].a != 255) {
tex.opaque = OpaqueType::Transparent;
stbi_image_free(data);
return tex;
}
}
stbi_image_free(data);
return tex;
}
}
}
stbi_image_free(data);
return tex;
}
};
}