Fry3DX/engine/render/resources.cpp
Nexoniarz f3e8eca87b Fry3DX 0.4: an easy 3D modeller
Shapes with live settings, shape editing, knife and hole tools, drawing
shapes onto other objects, combining objects (add / subtract / overlap),
copies, thickness, painting with stickers and layers, a Layout tab, a
Print tab with checks, packed .fry files with autosave, export to GLB,
glTF, FBX, OBJ, 3MF, STL and PLY, folders in Layers, its own crash
screen, and a website for GitHub Pages in docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NMSrd5oe1GpUD7E6d825KP
2026-10-19 21:18:35 +02:00

293 lines
14 KiB
C++

// Textures and models: loaded through the VFS, described here, uploaded by the backend.
#include "asset/kmdl.h"
#include "core/log.h"
#include "fs/vfs.h"
#include "render/renderer.h"
#include <yapf.h>
#include <algorithm>
#include <cmath>
#include <cstring>
namespace keel {
TextureId Renderer::newTexture(uint32_t w, uint32_t h, bool srgb, const std::vector<std::vector<uint8_t>>& mips, TexFormat format) {
if (nextTexture_ >= kMaxTextures) return kMissingTexture;
const TextureId id = nextTexture_++;
backend_->createTexture(id, w, h, srgb, mips, format);
textureSizes_[id] = glm::ivec2(int(w), int(h));
return id;
}
// Why YAPF couldn't read a file, in words.
static const char* yapfReason(int err) {
switch (err) {
case YAPF_ERR_INVALID: return "not a YAPF file";
case YAPF_ERR_IO: return "couldn't be read";
case YAPF_ERR_OOM: return "out of memory";
case YAPF_ERR_CORRUPT: return "damaged";
case YAPF_ERR_UNSUPPORTED: return "uses a feature this YAPF can't decode (a newer version?)";
case YAPF_ERR_CHECKSUM: return "damaged (checksum mismatch)";
case YAPF_ERR_TRUNCATED: return "truncated";
default: return "unknown error";
}
}
// A .yapf file (version 1 or 2) as mips for the GPU: BC7 blocks go up as they are when the GPU
// takes them; anything else (other block formats, 16-bit, half, float, gray) is decoded to RGBA8
// by YAPF (yapf_tex_to_rgba8).
bool Renderer::decodeTexture(const std::string& key, uint32_t& w, uint32_t& h, bool& srgb, std::vector<std::vector<uint8_t>>& mips,
TexFormat& format) {
auto data = fs::read(key);
if (!data) {
log::warn("Texture not found: {}", key);
return false;
}
int err = 0;
yapf_texture_t* tex = yapf_tex_load_memory(data->data(), data->size(), 0, 0, &err);
if (!tex) {
log::warn("{}: can't load: {} (YAPF error {})", key, yapfReason(err), err);
return false;
}
w = tex->width, h = tex->height, srgb = tex->flags & YAPF_FLAG_SRGB;
// Float and half textures are linear: yapf_tex_to_rgba8 sRGB-encodes them.
if (tex->payload != YAPF_PAYLOAD_BLOCKS && (tex->sample == YAPF_SAMPLE_F16 || tex->sample == YAPF_SAMPLE_F32)) srgb = true;
const bool upload = tex->payload == YAPF_PAYLOAD_BLOCKS && tex->format == YAPF_GPU_BC7 && backend_->blockCompression();
format = upload ? TexFormat::BC7 : TexFormat::RGBA8;
mips.clear();
for (uint32_t level = 0; level < tex->mip_levels; ++level) {
uint32_t lw = 0, lh = 0, ld = 0;
yapf_tex_mip_size(tex, level, &lw, &lh, &ld);
const uint32_t surface = yapf_tex_surface(tex, 0, 0, 0, level);
if (upload) {
const uint8_t* src = static_cast<const uint8_t*>(tex->data[surface]);
mips.emplace_back(src, src + yapf_tex_surface_bytes(tex, level));
continue;
}
std::vector<uint8_t> rgba(size_t(lw) * lh * 4);
if (const int e = yapf_tex_to_rgba8(tex, surface, 0, rgba.data()); e != YAPF_OK) {
log::warn("{}: mip {} can't be decoded: {}", key, level, yapfReason(e));
yapf_tex_free(tex);
return false;
}
mips.push_back(std::move(rgba));
}
yapf_tex_free(tex);
return true;
}
TextureId Renderer::createTexture(std::string_view name, uint32_t w, uint32_t h, const uint8_t* rgba) {
// A full mip chain (box filtered), so a big picture seen small doesn't shimmer.
std::vector<std::vector<uint8_t>> mips(1, std::vector<uint8_t>(rgba, rgba + size_t(w) * h * 4));
for (uint32_t mw = w, mh = h; mw > 1 || mh > 1;) {
const uint32_t nw = std::max(mw / 2, 1u), nh = std::max(mh / 2, 1u);
const std::vector<uint8_t>& src = mips.back();
std::vector<uint8_t> dst(size_t(nw) * nh * 4);
for (uint32_t y = 0; y < nh; ++y)
for (uint32_t x = 0; x < nw; ++x)
for (int c = 0; c < 4; ++c) {
const uint32_t x0 = std::min(x * 2, mw - 1), x1 = std::min(x * 2 + 1, mw - 1), y0 = std::min(y * 2, mh - 1), y1 = std::min(y * 2 + 1, mh - 1);
dst[(size_t(y) * nw + x) * 4 + c] = uint8_t((src[(size_t(y0) * mw + x0) * 4 + c] + src[(size_t(y0) * mw + x1) * 4 + c] +
src[(size_t(y1) * mw + x0) * 4 + c] + src[(size_t(y1) * mw + x1) * 4 + c] + 2) / 4);
}
mips.push_back(std::move(dst));
mw = nw, mh = nh;
}
const std::string key(name);
if (auto it = textureCache_.find(key); it != textureCache_.end() && it->second >= kFirstTextureSlot) {
backend_->createTexture(it->second, w, h, true, mips, TexFormat::RGBA8);
textureSizes_[it->second] = glm::ivec2(int(w), int(h));
return it->second;
}
return textureCache_[key] = newTexture(w, h, true, mips, TexFormat::RGBA8);
}
TextureId Renderer::loadTexture(std::string_view path) {
if (path.empty()) return kWhiteTexture;
const std::string key(path);
if (auto it = textureCache_.find(key); it != textureCache_.end()) return it->second;
TextureId id = kMissingTexture;
uint32_t w = 0, h = 0;
bool srgb = false;
std::vector<std::vector<uint8_t>> mips;
TexFormat format;
if (decodeTexture(key, w, h, srgb, mips, format)) id = newTexture(w, h, srgb, mips, format);
if (auto t = fs::mtime(key)) fileTimes_[key] = *t;
textureCache_[key] = id;
return id;
}
std::vector<std::string> Renderer::reloadChanged() {
std::vector<std::string> changed;
auto stale = [&](const std::string& path) {
const auto t = fs::mtime(path);
if (!t) return false;
auto [it, isNew] = fileTimes_.try_emplace(path, *t);
if (isNew || it->second == *t) return false;
it->second = *t;
return true;
};
for (auto& [path, id] : textureCache_) {
if (id < kFirstTextureSlot || !stale(path)) continue;
uint32_t w = 0, h = 0;
bool srgb = false;
std::vector<std::vector<uint8_t>> mips;
TexFormat format;
if (!decodeTexture(path, w, h, srgb, mips, format)) continue;
backend_->createTexture(id, w, h, srgb, mips, format); // the same slot: everything using it updates
textureSizes_[id] = glm::ivec2(int(w), int(h));
changed.push_back(path);
}
std::vector<std::pair<std::string, ModelId>> models(modelCache_.begin(), modelCache_.end());
for (auto& [path, id] : models) {
if (!id.valid() || path.starts_with("map:") || !stale(path)) continue;
auto data = fs::read(path);
auto kmdl = data ? readKmdl(*data) : std::nullopt;
if (!kmdl) continue;
updateModel(path, *kmdl);
changed.push_back(path);
}
return changed;
}
Renderer::Model Renderer::describeModel(const KmdlModel& kmdl) {
Model m;
if (kmdl.vertices.empty() || kmdl.indices.empty()) return m;
m.mins = glm::vec3(kmdl.header.boundsMin[0], kmdl.header.boundsMin[1], kmdl.header.boundsMin[2]);
m.maxs = glm::vec3(kmdl.header.boundsMax[0], kmdl.header.boundsMax[1], kmdl.header.boundsMax[2]);
m.skinned = !kmdl.skin.empty();
for (const KmdlSubmesh& s : kmdl.submeshes) {
const KmdlMaterial& mat = kmdl.materials[s.material];
const std::string tex(mat.texture, strnlen(mat.texture, sizeof mat.texture));
const std::string lm(mat.lightmap, strnlen(mat.lightmap, sizeof mat.lightmap));
// A material map next to the texture ("wall.yapf" → "wall_n.yapf"), when there is one.
TextureId material = 0;
if (const size_t dot = tex.rfind('.'); !tex.empty() && dot != std::string::npos)
if (const std::string n = tex.substr(0, dot) + "_n" + tex.substr(dot); fs::exists(n)) material = loadTexture(n);
TextureId skyVis = 0;
if (const size_t dot = lm.rfind('.'); !lm.empty() && dot != std::string::npos)
if (const std::string v = lm.substr(0, dot) + "_sky" + lm.substr(dot); fs::exists(v)) skyVis = loadTexture(v);
glm::vec3 lo(1e30f), hi(-1e30f);
for (uint32_t i = s.firstIndex; i < s.firstIndex + s.indexCount && i < kmdl.indices.size(); ++i) {
const float* p = kmdl.vertices[kmdl.indices[i]].position;
lo = glm::min(lo, glm::vec3(p[0], p[1], p[2]));
hi = glm::max(hi, glm::vec3(p[0], p[1], p[2]));
}
if (m.skinned) lo = m.mins - (m.maxs - m.mins) * 0.5f, hi = m.maxs + (m.maxs - m.mins) * 0.5f; // animated: room to move
m.submeshes.push_back({s.firstIndex, s.indexCount, loadTexture(tex), lm.empty() ? 0u : loadTexture(lm),
glm::vec4(mat.baseColor[0], mat.baseColor[1], mat.baseColor[2], mat.baseColor[3]), material, skyVis, lo, hi});
// A mirror: its plane from its first triangle, facing the way its vertices' normals do.
if (tex.find("mirror") != std::string::npos && s.indexCount >= 3 && !m.skinned) {
const KmdlVertex& va = kmdl.vertices[kmdl.indices[s.firstIndex]];
const glm::vec3 a(va.position[0], va.position[1], va.position[2]);
const float* pb = kmdl.vertices[kmdl.indices[s.firstIndex + 1]].position;
const float* pc = kmdl.vertices[kmdl.indices[s.firstIndex + 2]].position;
glm::vec3 n = glm::cross(glm::vec3(pb[0], pb[1], pb[2]) - a, glm::vec3(pc[0], pc[1], pc[2]) - a);
if (glm::length(n) > 1e-6f) {
n = glm::normalize(n);
if (glm::dot(n, glm::vec3(va.normal[0], va.normal[1], va.normal[2])) < 0.0f) n = -n;
m.submeshes.back().mirror = true;
m.submeshes.back().plane = glm::vec4(n, glm::dot(n, a));
}
}
}
return m;
}
namespace {
// Mirrors reflect in one plane each, but a model keeps all the faces of a texture in one
// submesh (several mirrors, or a mirror brush's faces). Each mirror submesh with more than one
// plane is split into one per plane (its triangles sorted so each runs on). False: nothing to do.
bool splitMirrors(const KmdlModel& in, KmdlModel& out) {
auto isMirror = [&](const KmdlSubmesh& s) {
const KmdlMaterial& m = in.materials[s.material];
return std::string(m.texture, strnlen(m.texture, sizeof m.texture)).find("mirror") != std::string::npos;
};
auto planeOf = [&](uint32_t first) {
const float* a = in.vertices[in.indices[first]].position;
const float* b = in.vertices[in.indices[first + 1]].position;
const float* c = in.vertices[in.indices[first + 2]].position;
const glm::vec3 pa(a[0], a[1], a[2]);
glm::vec3 n = glm::cross(glm::vec3(b[0], b[1], b[2]) - pa, glm::vec3(c[0], c[1], c[2]) - pa);
if (glm::length(n) < 1e-6f) return glm::ivec4(0);
n = glm::normalize(n);
const float* vn = in.vertices[in.indices[first]].normal;
if (glm::dot(n, glm::vec3(vn[0], vn[1], vn[2])) < 0.0f) n = -n;
return glm::ivec4(glm::round(n * 1000.0f), int(std::round(glm::dot(n, pa) * 2.0f))); // within 0.001 / half a unit
};
bool any = false;
for (const KmdlSubmesh& s : in.submeshes) {
if (!isMirror(s) || s.indexCount <= 3) continue;
const glm::ivec4 first = planeOf(s.firstIndex);
for (uint32_t t = s.firstIndex + 3; t + 2 < s.firstIndex + s.indexCount && !any; t += 3) any = planeOf(t) != first;
if (any) break;
}
if (!any) return false;
out = in;
out.submeshes.clear();
for (const KmdlSubmesh& s : in.submeshes) {
if (!isMirror(s)) {
out.submeshes.push_back(s);
continue;
}
std::vector<std::pair<glm::ivec4, std::vector<uint32_t>>> groups; // plane → its triangles' indices
for (uint32_t t = s.firstIndex; t + 2 < s.firstIndex + s.indexCount; t += 3) {
const glm::ivec4 pl = planeOf(t);
auto it = std::find_if(groups.begin(), groups.end(), [&](const auto& g) { return g.first == pl; });
if (it == groups.end()) it = groups.insert(groups.end(), {pl, {}});
it->second.insert(it->second.end(), {in.indices[t], in.indices[t + 1], in.indices[t + 2]});
}
uint32_t at = s.firstIndex;
for (const auto& [pl, idx] : groups) {
std::copy(idx.begin(), idx.end(), out.indices.begin() + at);
out.submeshes.push_back({at, uint32_t(idx.size()), s.material});
at += uint32_t(idx.size());
}
}
return true;
}
} // namespace
ModelId Renderer::createModel(std::string_view name, const KmdlModel& source) {
KmdlModel split;
const KmdlModel& kmdl = splitMirrors(source, split) ? split : source;
const std::string key(name);
if (auto it = modelCache_.find(key); it != modelCache_.end()) return it->second;
ModelId id;
if (!kmdl.vertices.empty() && !kmdl.indices.empty()) {
id.index = uint32_t(models_.size());
models_.push_back(describeModel(kmdl));
backend_->uploadModel(id.index, kmdl);
}
modelCache_[key] = id;
return id;
}
ModelId Renderer::updateModel(std::string_view name, const KmdlModel& source) {
KmdlModel split;
const KmdlModel& kmdl = splitMirrors(source, split) ? split : source;
const auto it = modelCache_.find(std::string(name));
if (it == modelCache_.end() || !it->second.valid()) {
if (it != modelCache_.end()) modelCache_.erase(it);
return createModel(name, kmdl);
}
models_[it->second.index] = describeModel(kmdl); // may be empty: draws nothing
backend_->uploadModel(it->second.index, kmdl);
return it->second;
}
ModelId Renderer::loadModel(std::string_view path) {
const std::string key(path);
if (auto it = modelCache_.find(key); it != modelCache_.end()) return it->second;
if (auto t = fs::mtime(key)) fileTimes_[key] = *t;
auto data = fs::read(path);
auto kmdl = data ? readKmdl(*data) : std::nullopt;
if (!kmdl) {
log::warn("Model {} {}", key, data ? "is not a valid KMDL file" : "not found");
return modelCache_[key] = ModelId{};
}
return createModel(key, *kmdl);
}
} // namespace keel