This commit is contained in:
Jorijn van der Graaf 2026-05-18 22:31:28 +02:00
commit 765cf33069
6 changed files with 10895 additions and 11 deletions

View file

@ -58,7 +58,7 @@ namespace Crafter::Compression {
GDeflate::MaximumCompressionLevel, GDeflate::MaximumCompressionLevel,
0); 0);
if (!ok) { if (!ok) {
throw std::runtime_error("GDeflate::Compress failed"); Fatal("GDeflate::Compress failed");
} }
out.resize(actualSize); out.resize(actualSize);
totalSize += actualSize; totalSize += actualSize;
@ -113,13 +113,13 @@ namespace Crafter::Compression {
void DecompressCPU(const CompressedBlob& blob, std::span<const std::span<std::byte>> outputs) { void DecompressCPU(const CompressedBlob& blob, std::span<const std::span<std::byte>> outputs) {
if (outputs.size() != blob.regions.size()) { if (outputs.size() != blob.regions.size()) {
throw std::runtime_error("DecompressCPU: outputs.size() != regions.size()"); Fatal("DecompressCPU: outputs.size() != regions.size()");
} }
for (std::size_t i = 0; i < blob.regions.size(); ++i) { for (std::size_t i = 0; i < blob.regions.size(); ++i) {
const RegionMeta& r = blob.regions[i]; const RegionMeta& r = blob.regions[i];
const std::span<std::byte>& out = outputs[i]; const std::span<std::byte>& out = outputs[i];
if (out.size() != r.decompressedSize) { if (out.size() != r.decompressedSize) {
throw std::runtime_error("DecompressCPU: output size mismatch"); Fatal("DecompressCPU: output size mismatch");
} }
if (r.decompressedSize == 0) continue; if (r.decompressedSize == 0) continue;
bool ok = GDeflate::Decompress( bool ok = GDeflate::Decompress(
@ -129,7 +129,7 @@ namespace Crafter::Compression {
r.compressedSize, r.compressedSize,
/*numWorkers=*/1); /*numWorkers=*/1);
if (!ok) { if (!ok) {
throw std::runtime_error("GDeflate::Decompress failed"); Fatal("GDeflate::Decompress failed");
} }
} }
} }

View file

@ -21,6 +21,17 @@ export module Crafter.Asset:Compression;
import std; import std;
export namespace Crafter::Compression { export namespace Crafter::Compression {
// Hard-aborts the process after printing `msg`. Used in place of
// `throw std::runtime_error(...)` so the module is buildable under
// `-fno-exceptions` (wasm targets ship without the C++ unwinding
// runtime). Asset-loading errors are unrecoverable in practice — a
// bad magic, version mismatch, or stream-size mismatch always means
// the build's data is inconsistent with the binary.
[[noreturn]] inline void Fatal(std::string_view msg) {
std::println(std::cerr, "Crafter.Asset fatal: {}", msg);
std::abort();
}
// One independently-decompressable GDeflate stream inside CompressedBlob::bytes. // One independently-decompressable GDeflate stream inside CompressedBlob::bytes.
// Layout matches what VK_EXT_memory_decompression's VkDecompressMemoryRegionEXT // Layout matches what VK_EXT_memory_decompression's VkDecompressMemoryRegionEXT
// expects, minus the device addresses (which the consumer fills in). // expects, minus the device addresses (which the consumer fills in).

View file

@ -24,6 +24,41 @@ import std;
namespace fs = std::filesystem; namespace fs = std::filesystem;
export namespace Crafter { export namespace Crafter {
// Minimal Wavefront .mtl record. Only diffuse-map path is captured;
// normal/specular/etc. would extend this in a backward-compatible way.
// `mapKd` is verbatim from the file (relative to the .mtl's directory)
// and empty if the material defined no diffuse texture.
struct MtlMaterial {
std::string mapKd;
};
// Parse a .mtl file into { materialName → MtlMaterial }. Strips trailing
// \r/\n/spaces on names + paths so DOS-line-ending files match the
// `usemtl` tokens in their sibling .obj. Materials without `map_Kd`
// are still present in the map with an empty `mapKd`.
inline std::unordered_map<std::string, MtlMaterial> LoadMTL(fs::path path) {
std::ifstream file(path);
std::unordered_map<std::string, MtlMaterial> result;
std::string current;
std::string line;
auto trim = [](std::string& s) {
while (!s.empty() && (s.back() == '\r' || s.back() == '\n' || s.back() == ' ' || s.back() == '\t')) s.pop_back();
while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.erase(s.begin());
};
while (std::getline(file, line)) {
if (line.starts_with("newmtl ")) {
current = line.substr(7);
trim(current);
result.try_emplace(current);
} else if (line.starts_with("map_Kd ") && !current.empty()) {
std::string val = line.substr(7);
trim(val);
result[current].mapKd = std::move(val);
}
}
return result;
}
struct __attribute__((packed)) VertexNormalTangentUVPacked { struct __attribute__((packed)) VertexNormalTangentUVPacked {
Vector<float, 3, 4> normal; Vector<float, 3, 4> normal;
Vector<float, 3, 4> tangent; Vector<float, 3, 4> tangent;
@ -58,12 +93,12 @@ export namespace Crafter {
char magic[4]; char magic[4];
file.read(magic, 4); file.read(magic, 4);
if (std::memcmp(magic, MeshAssetFormat::magic, 4) != 0) { if (std::memcmp(magic, MeshAssetFormat::magic, 4) != 0) {
throw std::runtime_error("LoadCompressedMesh: bad magic on " + path.string()); Compression::Fatal("LoadCompressedMesh: bad magic on " + path.string());
} }
std::uint32_t version = 0; std::uint32_t version = 0;
file.read(reinterpret_cast<char*>(&version), sizeof(version)); file.read(reinterpret_cast<char*>(&version), sizeof(version));
if (version != MeshAssetFormat::version) { if (version != MeshAssetFormat::version) {
throw std::runtime_error("LoadCompressedMesh: unsupported version on " + path.string()); Compression::Fatal("LoadCompressedMesh: unsupported version on " + path.string());
} }
CompressedMeshAsset out; CompressedMeshAsset out;
file.read(reinterpret_cast<char*>(&out.vertexCount), sizeof(out.vertexCount)); file.read(reinterpret_cast<char*>(&out.vertexCount), sizeof(out.vertexCount));
@ -143,6 +178,136 @@ export namespace Crafter {
return mesh; return mesh;
} }
// Parse a .obj split by `usemtl` directives. Returns one
// MeshAsset<T> per material group (in encounter order), each with
// its own deduplicated vertex/index/data arrays — i.e. shared
// vertices across materials are duplicated so each MeshAsset is
// self-contained and can become a standalone BLAS. Dedup is
// O(1)-per-vertex via a hash map keyed on (vIdx, vtIdx, vnIdx),
// unlike LoadOBJ's linear-scan dedup (which is O(N²) and gets
// pathological on Sponza-class inputs).
//
// Faces appearing before any `usemtl` accumulate under the empty
// string material name; consumers can decide whether to drop them
// or treat them as a default. Non-triangle faces are fan-triangulated
// around their first vertex.
static std::vector<std::pair<std::string, MeshAsset<T>>> LoadOBJSplit(fs::path path)
requires (std::same_as<T, VertexNormalTangentUVPacked> || std::same_as<T, VertexNormalTangentUV>) {
std::ifstream file(path);
std::vector<Vector<float, 3, 0>> positions;
std::vector<Vector<float, 3, 0>> normals;
std::vector<Vector<float, 2, 0>> uvs;
std::vector<std::pair<std::string, MeshAsset<T>>> result;
std::unordered_map<std::string, std::size_t> matIndex;
std::vector<std::unordered_map<std::uint64_t, std::uint32_t>> dedupPerMat;
std::size_t curMat = static_cast<std::size_t>(-1);
auto useMaterial = [&](std::string name) {
while (!name.empty() && (name.back() == '\r' || name.back() == '\n' || name.back() == ' ' || name.back() == '\t')) name.pop_back();
auto it = matIndex.find(name);
if (it != matIndex.end()) { curMat = it->second; return; }
matIndex.emplace(name, result.size());
result.emplace_back(std::move(name), MeshAsset<T>{});
dedupPerMat.emplace_back();
curMat = result.size() - 1;
};
std::string line;
while (std::getline(file, line)) {
if (line.starts_with("usemtl ")) {
useMaterial(line.substr(7));
} else if (line.starts_with("vt ")) {
std::istringstream iss(line.substr(3));
Vector<float, 2, 0> uv;
iss >> uv.x >> uv.y;
uvs.push_back(uv);
} else if (line.starts_with("vn ")) {
std::istringstream iss(line.substr(3));
Vector<float, 3, 0> n;
iss >> n.x >> n.y >> n.z;
normals.push_back(n);
} else if (line.starts_with("v ")) {
std::istringstream iss(line.substr(2));
Vector<float, 3, 3> p;
iss >> p.x >> p.y >> p.z;
positions.push_back(Vector<float, 3, 0>{p.x, p.y, p.z});
} else if (line.starts_with("f ")) {
if (curMat == static_cast<std::size_t>(-1)) useMaterial(std::string{});
MeshAsset<T>& mesh = result[curMat].second;
auto& dedup = dedupPerMat[curMat];
std::istringstream iss(line.substr(2));
std::string vg;
std::vector<std::uint32_t> faceIndices;
while (iss >> vg) {
std::istringstream vss(vg);
std::string vs, vts, vns;
std::getline(vss, vs, '/');
std::getline(vss, vts, '/');
std::getline(vss, vns, '/');
std::uint32_t vi = vs.empty() ? 0u : static_cast<std::uint32_t>(std::stoi(vs) - 1);
std::uint32_t vti = vts.empty() ? 0u : static_cast<std::uint32_t>(std::stoi(vts) - 1);
std::uint32_t vni = vns.empty() ? 0u : static_cast<std::uint32_t>(std::stoi(vns) - 1);
std::uint64_t key = (static_cast<std::uint64_t>(vi) << 42)
| (static_cast<std::uint64_t>(vti) << 21)
| static_cast<std::uint64_t>(vni);
if (auto it = dedup.find(key); it != dedup.end()) {
faceIndices.push_back(it->second);
} else {
std::uint32_t newIdx = static_cast<std::uint32_t>(mesh.vertexes.size());
const Vector<float, 3, 0>& p = positions[vi];
const Vector<float, 3, 0>& n = normals[vni];
const Vector<float, 2, 0>& uv = uvs[vti];
mesh.vertexes.push_back(Vector<float, 3, 3>{p.x, p.y, p.z});
mesh.datas.push_back(T{n, {0,0,0}, uv});
dedup.emplace(key, newIdx);
faceIndices.push_back(newIdx);
}
}
for (std::size_t i = 1; i + 1 < faceIndices.size(); ++i) {
mesh.indexes.push_back(faceIndices[0]);
mesh.indexes.push_back(faceIndices[i]);
mesh.indexes.push_back(faceIndices[i + 1]);
}
}
}
// Accumulate face tangents into each vertex's tangent slot,
// then normalize. Mirrors LoadOBJ's tangent calculation but
// guarded against degenerate UVs (the LoadOBJ form NaNs out
// silently — fine for the cube but blows up some Sponza tris).
for (auto& entry : result) {
MeshAsset<T>& mesh = entry.second;
for (std::uint32_t i = 0; i + 2 < mesh.indexes.size(); i += 3) {
std::uint32_t i0 = mesh.indexes[i];
std::uint32_t i1 = mesh.indexes[i + 1];
std::uint32_t i2 = mesh.indexes[i + 2];
Vector<float, 3, 0> edge1 = mesh.vertexes[i1] - mesh.vertexes[i0];
Vector<float, 3, 0> edge2 = mesh.vertexes[i2] - mesh.vertexes[i0];
Vector<float, 2, 0> dUV1 = mesh.datas[i1].uv - mesh.datas[i0].uv;
Vector<float, 2, 0> dUV2 = mesh.datas[i2].uv - mesh.datas[i0].uv;
float denom = dUV1.x * dUV2.y - dUV1.y * dUV2.x;
if (denom == 0.0f) continue;
float f = 1.0f / denom;
Vector<float, 3, 0> tangent;
tangent.x = f * (dUV2.y * edge1.x - dUV1.y * edge2.x);
tangent.y = f * (dUV2.y * edge1.y - dUV1.y * edge2.y);
tangent.z = f * (dUV2.y * edge1.z - dUV1.y * edge2.z);
tangent.Normalize();
mesh.datas[i0].tangent += tangent;
mesh.datas[i1].tangent += tangent;
mesh.datas[i2].tangent += tangent;
}
for (T& v : mesh.datas) {
v.tangent.Normalize();
}
}
return result;
}
static MeshAsset<T> LoadOBJ(fs::path path) requires (std::same_as<T, VertexNormalTangentUVPacked> || std::same_as<T, VertexNormalTangentUV>) { static MeshAsset<T> LoadOBJ(fs::path path) requires (std::same_as<T, VertexNormalTangentUVPacked> || std::same_as<T, VertexNormalTangentUV>) {
std::ifstream file(path); std::ifstream file(path);

View file

@ -20,6 +20,8 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
module; module;
#define STB_IMAGE_IMPLEMENTATION #define STB_IMAGE_IMPLEMENTATION
#include "../lib/stb_image.h" #include "../lib/stb_image.h"
#define STB_IMAGE_RESIZE_IMPLEMENTATION
#include "../lib/stb_image_resize2.h"
export module Crafter.Asset:Texture; export module Crafter.Asset:Texture;
import :Compression; import :Compression;
import std; import std;
@ -59,12 +61,12 @@ export namespace Crafter {
char magic[4]; char magic[4];
file.read(magic, 4); file.read(magic, 4);
if (std::memcmp(magic, TextureAssetFormat::magic, 4) != 0) { if (std::memcmp(magic, TextureAssetFormat::magic, 4) != 0) {
throw std::runtime_error("LoadCompressedTexture: bad magic on " + path.string()); Compression::Fatal("LoadCompressedTexture: bad magic on " + path.string());
} }
std::uint32_t version = 0; std::uint32_t version = 0;
file.read(reinterpret_cast<char*>(&version), sizeof(version)); file.read(reinterpret_cast<char*>(&version), sizeof(version));
if (version != TextureAssetFormat::version) { if (version != TextureAssetFormat::version) {
throw std::runtime_error("LoadCompressedTexture: unsupported version on " + path.string()); Compression::Fatal("LoadCompressedTexture: unsupported version on " + path.string());
} }
CompressedTextureAsset out; CompressedTextureAsset out;
file.read(reinterpret_cast<char*>(&out.sizeX), sizeof(out.sizeX)); file.read(reinterpret_cast<char*>(&out.sizeX), sizeof(out.sizeX));
@ -123,6 +125,22 @@ export namespace Crafter {
return tex; 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) { static TextureAssetInfo LoadInfo(fs::path path) {
TextureAssetInfo info; TextureAssetInfo info;
@ -159,7 +177,11 @@ export namespace Crafter {
tex.opaque = OpaqueType::FullyOpaque; tex.opaque = OpaqueType::FullyOpaque;
if constexpr(std::same_as<TT, _Float16> || std::same_as<TT, float> || std::same_as<TT, double>) { 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++) { for(std::uint32_t i = 0; i < sizeX*sizeY; i++) {
tex.pixels[i].r = TT(data[i*4])/255; tex.pixels[i].r = TT(data[i*4])/255;
tex.pixels[i].g = TT(data[i*4+1])/255; tex.pixels[i].g = TT(data[i*4+1])/255;

10679
lib/stb_image_resize2.h Normal file

File diff suppressed because it is too large Load diff

View file

@ -46,7 +46,12 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// through to /usr/include and pick up a system libdeflate of a different // through to /usr/include and pick up a system libdeflate of a different
// ABI (1.25 dropped LIBDEFLATEEXPORT) instead of the vendored 1.8 fork. // ABI (1.25 dropped LIBDEFLATEEXPORT) instead of the vendored 1.8 fork.
cfg.compileFlags.push_back(std::format("-I{}", fs::absolute("lib/gdeflate/libdeflate").string())); cfg.compileFlags.push_back(std::format("-I{}", fs::absolute("lib/gdeflate/libdeflate").string()));
const std::array<fs::path, 12> libdeflateSources = { // libdeflate's lib/x86/ subdir is x86-specific CPU-feature detection
// (CPUID + AVX checks); compiling it for wasm32 fails on the inline
// assembly and missing intrinsics. wasm32 builds skip it; libdeflate's
// dispatcher falls through to the generic C path.
const bool isWasm = cfg.target.find("wasm") != std::string::npos;
std::vector<fs::path> libdeflateSources = {
"lib/gdeflate/libdeflate/lib/adler32", "lib/gdeflate/libdeflate/lib/adler32",
"lib/gdeflate/libdeflate/lib/crc32", "lib/gdeflate/libdeflate/lib/crc32",
"lib/gdeflate/libdeflate/lib/deflate_compress", "lib/gdeflate/libdeflate/lib/deflate_compress",
@ -58,8 +63,10 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
"lib/gdeflate/libdeflate/lib/utils", "lib/gdeflate/libdeflate/lib/utils",
"lib/gdeflate/libdeflate/lib/zlib_compress", "lib/gdeflate/libdeflate/lib/zlib_compress",
"lib/gdeflate/libdeflate/lib/zlib_decompress", "lib/gdeflate/libdeflate/lib/zlib_decompress",
"lib/gdeflate/libdeflate/lib/x86/cpu_features",
}; };
if (!isWasm) {
libdeflateSources.emplace_back("lib/gdeflate/libdeflate/lib/x86/cpu_features");
}
for (const fs::path& p : libdeflateSources) cfg.cFiles.push_back(p); for (const fs::path& p : libdeflateSources) cfg.cFiles.push_back(p);
std::array<fs::path, 4> ifaces = { std::array<fs::path, 4> ifaces = {