From bea6b17b2584086d7c5b76265009070006204fee Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Mon, 28 Sep 2026 17:33:47 -0500 Subject: [PATCH] feat(ugc): hidden faces removed as LU Toolbox's Remove Hidden Faces decides them The 42 depth renders only saw faces in direct view, so faces reached only by bounced light (interiors, recesses, rooms seen through openings) were removed. UgcHsr traces the paths LU Toolbox's Cycles bake traces, directly from points on each opaque triangle, and removes a triangle only when none of its paths reaches the sky: - points in rows along the triangle's longest side, hsr_sample_spacing apart (0.1143, 7 x 7 on a stud-sized square), at least hsr_min_points (28, the texels LU Toolbox bakes for a triangle), at most 4096 - hsr_samples (8) paths from each point, at most hsr_bounces (8) bounces, as Cycles 3.1 samples the bake material (Principled BSDF defaults: Burley diffuse and GGX specular, defensive sampling, Filter Glossy, 4 glossy bounces, Russian roulette from the second bounce, ensure_valid_reflection); no direct sky sampling (Cycles doesn't sample a flat world as a light) - hsr_ground_plane: LU Toolbox's black box under LDD's floor - decided per triangle, deterministic per model; triangles without area removed - the VC pre-pass isn't done Checked against LU Toolbox's operator in Blender 3.1.2 on the same meshes (27 models, 937,814 triangles): 501,362 removed there, 501,172 here, differences as large as LU Toolbox's own between seeds. optimize_resolution is retired; remove_hidden_faces=0 makes the same files as before. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/routes/SettingsCatalog.cpp | 9 +- dUgcServer/CMakeLists.txt | 3 +- dUgcServer/UgcHsr.cpp | 723 ++++++++++++++++++++ dUgcServer/UgcHsr.h | 52 ++ dUgcServer/UgcJobs.cpp | 17 +- dUgcServer/UgcJobs.h | 3 +- dUgcServer/UgcRender.cpp | 107 --- dUgcServer/UgcRender.h | 26 +- dUgcServer/UgcServer.cpp | 9 +- docs/UgcServer.md | 61 +- resources/ugcconfig.ini | 12 +- tests/dUgcTests/UgcTests.cpp | 148 +++- 12 files changed, 1012 insertions(+), 158 deletions(-) create mode 100644 dUgcServer/UgcHsr.cpp create mode 100644 dUgcServer/UgcHsr.h diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 6ee4abf0f..900832476 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -483,9 +483,12 @@ namespace { c.Add(Text(UGC, "satin_colors", "Satin colors", "Satin (opal) color ids, comma separated: they stay transparent plastic (the client has no satin shader) but are made milky and less see-through. By default LEGO's satin colors 360,362,363,364,365,366,367,376 (none: off)." + notLive, "360,362,363,364,365,366,367,376")); c.Add(Float(UGC, "satin_opacity", "Satin opacity", "Percent: the opacity of transparent satin bricks, instead of the transparent opacity.", "75", 0, 100)); c.Add(Float(UGC, "satin_whiten", "Satin whitening", "Percent: how far satin colors go towards white.", "20", 0, 100)); - c.Add(Bool(UGC, "remove_hidden_faces", "Remove faces nobody can see", "", true)); - c.Add(Bool(UGC, "hsr_ground_plane", "Nothing seen from below", "Also removes what can only be seen from under the model.", false)); - c.Add(Unit(Int(UGC, "optimize_resolution", "Detail of the visibility renders", "", "1024", 64, 4096), "pixels")); + c.Add(Bool(UGC, "remove_hidden_faces", "Remove faces nobody can see", "LU Toolbox's Remove Hidden Faces: paths are traced from points on each opaque triangle, bouncing off the model; a triangle none of whose paths reaches the sky is removed.", true)); + c.Add(Bool(UGC, "hsr_ground_plane", "Nothing seen from below", "Also removes what can only be seen from under the model (a black ground under LDD's floor).", false)); + c.Add(Int(UGC, "hsr_samples", "Paths per point", "Paths traced from each point on a triangle (LU Toolbox's Samples). More finds more faces seen only through small gaps; slower.", "8", 1, 256)); + c.Add(Int(UGC, "hsr_bounces", "Path bounces", "Bounces a path may take off the model before it gives up (Cycles' Max Bounces in LU Toolbox's bake).", "8", 0, 64)); + c.Add(Float(UGC, "hsr_sample_spacing", "Point spacing", "Distance between the points paths start from, in LDD units (a stud is 0.8; 0.1143 puts 7 x 7 points on a stud-sized square). Smaller finds smaller visible parts of big faces; slower.", "0.1143", 0.01, 10)); + c.Add(Int(UGC, "hsr_min_points", "Points per triangle at least", "Small triangles get this many points however close together (LU Toolbox bakes 28 texels for every triangle). Lower is faster but removes more faces that are only just seen.", "28", 1, 4096)); c.Add(Bool(UGC, "bake_ao", "Darken hidden corners", "Ambient occlusion baked into the vertex colors.", true)); c.Add(Int(UGC, "ao_samples", "Occlusion rays per vertex", "", "64", 1, 1024)); c.Add(Float(UGC, "ao_distance", "Occlusion distance", "", "5", 0, 1000)); diff --git a/dUgcServer/CMakeLists.txt b/dUgcServer/CMakeLists.txt index 5393bf49c..a9b20a349 100644 --- a/dUgcServer/CMakeLists.txt +++ b/dUgcServer/CMakeLists.txt @@ -3,6 +3,7 @@ set(DUGC_SOURCES "UgcBricks.cpp" "UgcFormats.cpp" "UgcGlitter.cpp" + "UgcHsr.cpp" "UgcIconParams.cpp" "UgcIconPose.cpp" "UgcJobs.cpp" @@ -41,5 +42,5 @@ target_link_libraries(UgcServer ${COMMON_LIBRARIES} dWeb dServer dUgc) # Rendering is far too slow unoptimized (minutes a model instead of seconds), so it is optimized in every build type if(NOT MSVC) - set_source_files_properties("UgcRender.cpp" "UgcModel.cpp" "UgcPalette.cpp" "UgcGlitter.cpp" PROPERTIES COMPILE_OPTIONS "-O2") + set_source_files_properties("UgcRender.cpp" "UgcHsr.cpp" "UgcModel.cpp" "UgcPalette.cpp" "UgcGlitter.cpp" PROPERTIES COMPILE_OPTIONS "-O2") endif() diff --git a/dUgcServer/UgcHsr.cpp b/dUgcServer/UgcHsr.cpp new file mode 100644 index 000000000..b11cd87c0 --- /dev/null +++ b/dUgcServer/UgcHsr.cpp @@ -0,0 +1,723 @@ +#include "UgcHsr.h" + +#include +#include +#include +#include +#include + +#include "UgcThrottle.h" + +namespace { + constexpr float INF = std::numeric_limits::infinity(); + constexpr float PI = 3.14159265358979f; + // Blender's default Glossy bounces (LU Toolbox leaves it) + constexpr int GLOSSY_BOUNCES = 4; + // Points on one triangle at most (very big triangles get their points further apart) + constexpr size_t MAX_POINTS = 4096; + + // PCG32 (O'Neill), seeded per path so every path is the same whatever order they're traced in + class Random { + public: + explicit Random(uint64_t seed) { + m_State = 0; + Next(); + m_State += seed; + Next(); + } + + uint32_t Next() { + const uint64_t old = m_State; + m_State = old * 6364136223846793005ull + 1442695040888963407ull; + const auto xorshifted = static_cast(((old >> 18u) ^ old) >> 27u); + const auto rot = static_cast(old >> 59u); + return (xorshifted >> rot) | (xorshifted << ((32u - rot) & 31u)); + } + + // In [0, 1) + float Float() { return static_cast(Next() >> 8) * (1.0f / 16777216.0f); } + + private: + uint64_t m_State; + }; + + uint64_t Mix(uint64_t h) { + h ^= h >> 33; + h *= 0xff51afd7ed558ccdull; + h ^= h >> 33; + h *= 0xc4ceb9fe1a85ec53ull; + h ^= h >> 33; + return h; + } + + uint64_t PathSeed(uint64_t seed, uint64_t triangle, uint64_t point, uint64_t sample) { + return Mix(Mix(Mix(seed ^ 0x9E3779B97F4A7C15ull) ^ triangle) ^ (point << 16 | sample)); + } + + // Directions around a unit normal (Duff et al., "Building an Orthonormal Basis, Revisited") + void Basis(const glm::vec3& n, glm::vec3& t, glm::vec3& b) { + const float sign = std::copysign(1.0f, n.z); + const float a = -1.0f / (sign + n.z); + const float c = n.x * n.y * a; + t = glm::vec3(1.0f + sign * n.x * n.x * a, sign * c, -sign * n.x); + b = glm::vec3(c, sign + n.y * n.y * a, -n.y); + } + + glm::vec3 CosineHemisphere(const glm::vec3& n, float u, float v) { + glm::vec3 t, b; + Basis(n, t, b); + const float r = std::sqrt(u), phi = 2.0f * PI * v; + return t * (r * std::cos(phi)) + b * (r * std::sin(phi)) + n * std::sqrt(std::max(0.0f, 1.0f - u)); + } + + /** + * Cycles' ensure_valid_reflection, which the Principled BSDF applies to its normal: N turned towards Ng just + * enough that the mirror reflection of I (towards where the light goes) stays above the surface. + */ + glm::vec3 EnsureValidReflection(const glm::vec3& ng, const glm::vec3& i, const glm::vec3& n) { + const auto r = 2.0f * glm::dot(n, i) * n - i; + const float threshold = std::min(0.9f * glm::dot(ng, i), 0.01f); + if (glm::dot(ng, r) >= threshold) return n; + const float ndotng = glm::dot(n, ng); + const auto x = glm::normalize(n - ndotng * ng); + const float ix = glm::dot(i, x), iz = glm::dot(i, ng); + const float ix2 = ix * ix, iz2 = iz * iz; + const float a = ix2 + iz2; + const float b = std::sqrt(std::max(ix2 * (a - threshold * threshold), 0.0f)); + const float c = iz * threshold + a; + const float fac = 0.5f / a; + const float n1z2 = fac * (b + c), n2z2 = fac * (-b + c); + bool valid1 = n1z2 > 1e-5f && n1z2 <= 1.0f + 1e-5f; + bool valid2 = n2z2 > 1e-5f && n2z2 <= 1.0f + 1e-5f; + const auto root = [](float v) { return std::sqrt(std::max(v, 0.0f)); }; + glm::vec2 chosen; + if (valid1 && valid2) { + const glm::vec2 n1(root(1.0f - n1z2), root(n1z2)), n2(root(1.0f - n2z2), root(n2z2)); + const float r1 = 2.0f * (n1.x * ix + n1.y * iz) * n1.y - iz; + const float r2 = 2.0f * (n2.x * ix + n2.y * iz) * n2.y - iz; + valid1 = r1 >= 1e-5f; + valid2 = r2 >= 1e-5f; + if (valid1 && valid2) chosen = r1 < r2 ? n1 : n2; + else chosen = r1 > r2 ? n1 : n2; + } else if (valid1 || valid2) { + const float z2 = valid1 ? n1z2 : n2z2; + chosen = glm::vec2(root(1.0f - z2), root(z2)); + } else { + return ng; + } + return chosen.x * x + chosen.y * ng; + } + + float SchlickFresnel(float u) { + const float m = std::clamp(1.0f - u, 0.0f, 1.0f); + const float m2 = m * m; + return m2 * m2 * m; + } + + float FresnelDielectricCos(float cosi, float eta) { + const float c = std::abs(cosi); + float g = eta * eta - 1.0f + c * c; + if (!(g > 0.0f)) return 1.0f; + g = std::sqrt(g); + const float a = (g - c) / (g + c); + const float b = (c * (g + c) - 1.0f) / (c * (g - c) + 1.0f); + return 0.5f * a * a * (1.0f + b * b); + } + + /** + * The bake material: a new material's Principled BSDF (Blender 3.1: base color 0.8, specular 0.5, roughness 0.5, + * GGX) as Cycles 3.1 samples it, a diffuse closure (Burley, with retro-reflection) and a specular one (GGX with a + * dielectric Fresnel, IOR 1.5) picked by their sample weights. Only the directions a path takes and its throughput + * (for the Russian roulette) matter here. + */ + class Principled { + public: + static constexpr float BASE = 0.8f; // Base Color + static constexpr float ROUGHNESS = 0.5f; // Roughness + static constexpr float IOR = 1.5f; // (2 / (1 - sqrt(0.08 * Specular 0.5))) - 1 + static constexpr float CSPEC0 = 0.04f; // Specular 0.5 * 0.08 + + // n: the material's normal (after EnsureValidReflection), i: towards where the path came from, ng: the + // geometric normal on the same side, sn: the shading normal. `first`: the bake point (Cycles' defensive + // sampling there); minRayPdf: the smallest bounce pdf so far (Cycles' Filter Glossy 1 blurs the specular) + Principled(const glm::vec3& n, const glm::vec3& i, const glm::vec3& ng, const glm::vec3& sn, bool first, float minRayPdf) + : m_N(n), m_I(i), m_Ng(ng), m_Sn(sn) { + m_F0 = FresnelDielectricCos(1.0f, IOR); + m_WeightDiffuse = BASE; + m_WeightSpecular = Fresnel(m_I, m_N); // the closure's weight 1 times its Fresnel color's average + if (first) { + const float sum = m_WeightDiffuse + m_WeightSpecular; + m_WeightDiffuse = std::max(m_WeightDiffuse, 0.125f * sum); + m_WeightSpecular = std::max(m_WeightSpecular, 0.125f * sum); + } + m_Alpha = std::clamp(ROUGHNESS * ROUGHNESS, 0.0f, 1.0f); + if (minRayPdf < 1.0f) m_Alpha = std::max(std::sqrt(1.0f - minRayPdf) * 0.5f, m_Alpha); + } + + struct Sample { + glm::vec3 direction{}; + float pdf{}; // of the mixture; 0: nothing sampled, the path ends + float throughput{}; // eval / pdf + bool glossy{}; + }; + + Sample Draw(Random& random) const { + Sample sample; + const float pick = random.Float() * (m_WeightDiffuse + m_WeightSpecular); + const float u = random.Float(), v = random.Float(); + float pdfDiffuse = 0.0f, pdfSpecular = 0.0f; + float eval = 0.0f; + if (pick < m_WeightDiffuse) { + sample.direction = CosineHemisphere(m_N, u, v); + if (!(glm::dot(m_Ng, sample.direction) > 0.0f)) return sample; + eval = EvalDiffuse(sample.direction, pdfDiffuse); + if (!(pdfDiffuse > 0.0f) || !(eval > 0.0f)) return sample; + if (!Transmission(sample.direction)) eval += EvalSpecular(sample.direction, pdfSpecular); + } else { + sample.glossy = true; + if (!SampleSpecular(u, v, sample.direction, eval, pdfSpecular)) return sample; + if (!Transmission(sample.direction)) eval += EvalDiffuse(sample.direction, pdfDiffuse); + } + sample.pdf = (pdfDiffuse * m_WeightDiffuse + pdfSpecular * m_WeightSpecular) / (m_WeightDiffuse + m_WeightSpecular); + sample.throughput = sample.pdf > 0.0f ? eval / sample.pdf : 0.0f; + return sample; + } + + private: + // Cycles decides reflection or transmission by the shading normal; both closures only reflect + bool Transmission(const glm::vec3& l) const { return glm::dot(m_Sn, l) < 0.0f; } + + float Fresnel(const glm::vec3& l, const glm::vec3& h) const { + const float fh = (FresnelDielectricCos(glm::dot(l, h), IOR) - m_F0) / (1.0f - m_F0); + return CSPEC0 * (1.0f - fh) + fh; + } + + // bsdf_principled_diffuse_eval_reflect times the closure weight + float EvalDiffuse(const glm::vec3& l, float& pdf) const { + const float nl = glm::dot(m_N, l); + if (!(nl > 0.0f)) { + pdf = 0.0f; + return 0.0f; + } + pdf = nl / PI; + const float nv = glm::dot(m_N, m_I); + const float fv = SchlickFresnel(nv), fl = SchlickFresnel(nl); + float f = (1.0f - 0.5f * fv) * (1.0f - 0.5f * fl); + const float rr = ROUGHNESS * (glm::dot(l, m_I) + 1.0f); + f += rr * (fl + fv + fl * fv * (rr - 1.0f)); + return BASE * nl / PI * f; + } + + // bsdf_microfacet_ggx_eval_reflect (isotropic, Fresnel) times the closure weight 1 + float EvalSpecular(const glm::vec3& l, float& pdf) const { + pdf = 0.0f; + const float cosNO = glm::dot(m_N, m_I), cosNI = glm::dot(m_N, l); + if (!(cosNI > 0.0f && cosNO > 0.0f) || m_Alpha * m_Alpha <= 1e-7f) return 0.0f; + const auto m = glm::normalize(l + m_I); + const float alpha2 = m_Alpha * m_Alpha; + const float cosThetaM = glm::dot(m_N, m); + const float cosThetaM2 = cosThetaM * cosThetaM; + const float tanThetaM2 = (1.0f - cosThetaM2) / cosThetaM2; + const float d = alpha2 / (PI * cosThetaM2 * cosThetaM2 * (alpha2 + tanThetaM2) * (alpha2 + tanThetaM2)); + const float g1o = 2.0f / (1.0f + std::sqrt(std::max(0.0f, 1.0f + alpha2 * (1.0f - cosNO * cosNO) / (cosNO * cosNO)))); + const float g1i = 2.0f / (1.0f + std::sqrt(std::max(0.0f, 1.0f + alpha2 * (1.0f - cosNI * cosNI) / (cosNI * cosNI)))); + const float common = d * 0.25f / cosNO; + pdf = g1o * common; + return Fresnel(l, m) * g1o * g1i * common; + } + + // bsdf_microfacet_ggx_sample (isotropic): a visible normal (Heitz and d'Eon), the view reflected in it + bool SampleSpecular(float randu, float randv, glm::vec3& l, float& eval, float& pdf) const { + const float cosNO = glm::dot(m_N, m_I); + if (!(cosNO > 0.0f)) return false; + glm::vec3 x, y; + Basis(m_N, x, y); + // Stretch the view, sample the slopes, rotate and unstretch + auto local = glm::normalize(glm::vec3(m_Alpha * glm::dot(x, m_I), m_Alpha * glm::dot(y, m_I), cosNO)); + float costheta = 1.0f, sintheta = 0.0f, cosphi = 1.0f, sinphi = 0.0f; + if (local.z < 0.99999f) { + costheta = local.z; + sintheta = std::sqrt(std::max(0.0f, 1.0f - costheta * costheta)); + cosphi = local.x / sintheta; + sinphi = local.y / sintheta; + } + float slopeX, slopeY, g1o; + if (costheta >= 0.99999f) { + const float r = std::sqrt(randu / (1.0f - randu)); + const float phi = 2.0f * PI * randv; + slopeX = r * std::cos(phi); + slopeY = r * std::sin(phi); + g1o = 1.0f; + } else { + const float tanThetaI = sintheta / costheta; + const float g1Inv = 0.5f * (1.0f + std::sqrt(std::max(0.0f, 1.0f + tanThetaI * tanThetaI))); + g1o = 1.0f / g1Inv; + const float a = 2.0f * randu * g1Inv - 1.0f; + const float aa = a * a; + const float tmp = 1.0f / (aa - 1.0f); + const float b = tanThetaI, bb = b * b; + const float dd = std::sqrt(std::max(0.0f, bb * (tmp * tmp) - (aa - bb) * tmp)); + const float x1 = b * tmp - dd, x2 = b * tmp + dd; + slopeX = (a < 0.0f || x2 * tanThetaI > 1.0f) ? x1 : x2; + float s; + if (randv > 0.5f) { + s = 1.0f; + randv = 2.0f * (randv - 0.5f); + } else { + s = -1.0f; + randv = 2.0f * (0.5f - randv); + } + const float z = (randv * (randv * (randv * 0.27385f - 0.73369f) + 0.46341f)) / (randv * (randv * (randv * 0.093073f + 0.309420f) - 1.0f) + 0.597999f); + slopeY = s * z * std::sqrt(std::max(0.0f, 1.0f + slopeX * slopeX)); + } + const float rotated = cosphi * slopeX - sinphi * slopeY; + slopeY = sinphi * slopeX + cosphi * slopeY; + slopeX = rotated * m_Alpha; + slopeY *= m_Alpha; + const auto localM = glm::normalize(glm::vec3(-slopeX, -slopeY, 1.0f)); + const auto m = x * localM.x + y * localM.y + m_N * localM.z; + const float cosMO = glm::dot(m, m_I); + if (!(cosMO > 0.0f)) return false; + l = 2.0f * cosMO * m - m_I; + if (!(glm::dot(m_Ng, l) > 0.0f)) return false; + const float alpha2 = m_Alpha * m_Alpha; + const float cosThetaM2 = localM.z * localM.z; + const float tanThetaM2 = 1.0f / cosThetaM2 - 1.0f; + const float d = alpha2 / (PI * cosThetaM2 * cosThetaM2 * (alpha2 + tanThetaM2) * (alpha2 + tanThetaM2)); + const float cosNI = glm::dot(m_N, l); + const float g1i = 2.0f / (1.0f + std::sqrt(std::max(0.0f, 1.0f + alpha2 * (1.0f - cosNI * cosNI) / (cosNI * cosNI)))); + const float common = g1o * d * 0.25f / cosNO; + pdf = common; + eval = g1i * common * Fresnel(l, m); + return pdf > 0.0f && eval > 0.0f; + } + + glm::vec3 m_N, m_I, m_Ng, m_Sn; + float m_F0{}; + float m_WeightDiffuse{}, m_WeightSpecular{}; + float m_Alpha{}; + }; + + struct Hit { + float t{ INF }; + uint32_t triangle{ UINT32_MAX }; + float u{}, v{}; // weights of the triangle's second and third vertex + }; + + /** + * A bounding volume hierarchy (binned surface area heuristic) over a mesh's triangles, for the paths' rays: the + * nearest hit. A ray never hits the triangle it leaves (`skip`), as in Cycles. + */ + class Bvh { + public: + explicit Bvh(const UgcModel::Mesh& mesh) { + const size_t count = mesh.TriangleCount(); + std::vector order(count); + std::iota(order.begin(), order.end(), 0u); + std::vector lo(count), hi(count), centre(count); + for (size_t t = 0; t < count; t++) { + const auto& a = mesh.positions[mesh.indices[t * 3]]; + const auto& b = mesh.positions[mesh.indices[t * 3 + 1]]; + const auto& c = mesh.positions[mesh.indices[t * 3 + 2]]; + lo[t] = glm::min(a, glm::min(b, c)); + hi[t] = glm::max(a, glm::max(b, c)); + centre[t] = (lo[t] + hi[t]) * 0.5f; + } + if (count > 0) Build(order, lo, hi, centre); + m_Triangles.reserve(count); + for (const auto t : order) { + const auto& a = mesh.positions[mesh.indices[t * 3]]; + m_Triangles.push_back({ a, mesh.positions[mesh.indices[t * 3 + 1]] - a, mesh.positions[mesh.indices[t * 3 + 2]] - a, t }); + } + } + + // The nearest triangle along the ray (unit direction) before `maxT` + Hit Closest(const glm::vec3& origin, const glm::vec3& direction, uint32_t skip, float maxT = INF) const { + Hit hit; + hit.t = maxT; + if (m_Nodes.empty()) return hit; + const auto inverse = Inverse(direction); + uint32_t stack[128]; + int top = 0; + uint32_t index = 0; + while (true) { + const auto& node = m_Nodes[index]; + if (node.count > 0) { + for (uint32_t i = node.first; i < node.first + node.count; i++) Intersect(m_Triangles[i], origin, direction, skip, hit); + } else { + const uint32_t left = node.first, right = node.first + 1; + const float tl = Enter(m_Nodes[left], origin, inverse, hit.t); + const float tr = Enter(m_Nodes[right], origin, inverse, hit.t); + if (tl <= tr) { + if (tr != INF && top < 128) stack[top++] = right; + if (tl != INF) { index = left; continue; } + } else { + if (tl != INF && top < 128) stack[top++] = left; + index = right; + continue; + } + } + // Next from the stack, skipping nodes now farther than the nearest hit + bool found = false; + while (top > 0) { + index = stack[--top]; + if (Enter(m_Nodes[index], origin, inverse, hit.t) != INF) { + found = true; + break; + } + } + if (!found) break; + } + return hit; + } + + private: + struct Node { + glm::vec3 min{ INF }; + uint32_t first{}; // leaf: first triangle; inner: the left child (the right one follows it) + glm::vec3 max{ -INF }; + uint32_t count{}; // triangles, 0 for inner nodes + }; + + // Where the ray enters the node's box, INF when it misses it before `maxT` + static float Enter(const Node& node, const glm::vec3& origin, const glm::vec3& inverse, float maxT) { + const auto t0 = (node.min - origin) * inverse; + const auto t1 = (node.max - origin) * inverse; + const auto near = glm::min(t0, t1), far = glm::max(t0, t1); + const float enter = std::max(std::max(near.x, near.y), std::max(near.z, 0.0f)); + const float exit = std::min(std::min(far.x, far.y), std::min(far.z, maxT)); + return enter <= exit ? enter : INF; + } + + struct Triangle { + glm::vec3 a, e1, e2; + uint32_t index; + }; + + static glm::vec3 Inverse(const glm::vec3& d) { + const auto safe = [](float x) { return 1.0f / (std::abs(x) > 1e-20f ? x : std::copysign(1e-20f, x)); }; + return { safe(d.x), safe(d.y), safe(d.z) }; + } + + // Möller-Trumbore; keeps the hit when it's nearer than hit.t + static bool Intersect(const Triangle& tri, const glm::vec3& origin, const glm::vec3& direction, uint32_t skip, Hit& hit) { + if (tri.index == skip) return false; + const auto p = glm::cross(direction, tri.e2); + const float det = glm::dot(tri.e1, p); + if (det == 0.0f) return false; + const float inv = 1.0f / det; + const auto s = origin - tri.a; + const float u = glm::dot(s, p) * inv; + if (u < 0.0f || u > 1.0f) return false; + const auto q = glm::cross(s, tri.e1); + const float v = glm::dot(direction, q) * inv; + if (v < 0.0f || u + v > 1.0f) return false; + const float t = glm::dot(tri.e2, q) * inv; + if (!(t > 0.0f) || t >= hit.t) return false; + hit.t = t; + hit.triangle = tri.index; + hit.u = u; + hit.v = v; + return true; + } + + void Build(std::vector& order, const std::vector& lo, const std::vector& hi, const std::vector& centre) { + constexpr int BINS = 16; + constexpr uint32_t LEAF = 4; + struct Task { uint32_t node, first, count; }; + const auto area = [](const glm::vec3& min, const glm::vec3& max) { + const auto d = glm::max(max - min, glm::vec3(0.0f)); + return d.x * d.y + d.y * d.z + d.z * d.x; + }; + m_Nodes.reserve(order.size() * 2 / LEAF + 1); + m_Nodes.push_back({}); + std::vector tasks{ { 0, 0, static_cast(order.size()) } }; + while (!tasks.empty()) { + const auto task = tasks.back(); + tasks.pop_back(); + Node node; + glm::vec3 cmin(INF), cmax(-INF); + for (uint32_t i = task.first; i < task.first + task.count; i++) { + node.min = glm::min(node.min, lo[order[i]]); + node.max = glm::max(node.max, hi[order[i]]); + cmin = glm::min(cmin, centre[order[i]]); + cmax = glm::max(cmax, centre[order[i]]); + } + node.first = task.first; + node.count = task.count; + int bestAxis = -1; + int bestSplit = 0; + float bestCost = static_cast(task.count) * area(node.min, node.max); // not splitting + if (task.count > LEAF) { + for (int axis = 0; axis < 3; axis++) { + const float extent = cmax[axis] - cmin[axis]; + if (!(extent > 0.0f)) continue; + struct Bin { glm::vec3 min{ INF }, max{ -INF }; uint32_t count{}; }; + std::array bins{}; + const float scale = BINS / extent; + for (uint32_t i = task.first; i < task.first + task.count; i++) { + const auto t = order[i]; + const int b = std::min(BINS - 1, static_cast((centre[t][axis] - cmin[axis]) * scale)); + bins[b].min = glm::min(bins[b].min, lo[t]); + bins[b].max = glm::max(bins[b].max, hi[t]); + bins[b].count++; + } + std::array leftCost{}; + glm::vec3 lmin(INF), lmax(-INF); + uint32_t lcount = 0; + for (int b = 0; b < BINS - 1; b++) { + lmin = glm::min(lmin, bins[b].min); + lmax = glm::max(lmax, bins[b].max); + lcount += bins[b].count; + leftCost[b] = lcount ? lcount * area(lmin, lmax) : 0.0f; + } + glm::vec3 rmin(INF), rmax(-INF); + uint32_t rcount = 0; + for (int b = BINS - 1; b > 0; b--) { + rmin = glm::min(rmin, bins[b].min); + rmax = glm::max(rmax, bins[b].max); + rcount += bins[b].count; + const float cost = leftCost[b - 1] + (rcount ? rcount * area(rmin, rmax) : 0.0f); + if (rcount > 0 && rcount < task.count && cost < bestCost) { + bestCost = cost; + bestAxis = axis; + bestSplit = b; + } + } + } + } + if (bestAxis < 0) { + m_Nodes[task.node] = node; + continue; + } + const float extent = cmax[bestAxis] - cmin[bestAxis]; + const float scale = BINS / extent; + auto* begin = order.data() + task.first; + auto* middle = std::partition(begin, begin + task.count, [&](uint32_t t) { + return std::min(BINS - 1, static_cast((centre[t][bestAxis] - cmin[bestAxis]) * scale)) < bestSplit; + }); + const auto leftCount = static_cast(middle - begin); + node.first = static_cast(m_Nodes.size()); + node.count = 0; + m_Nodes[task.node] = node; + m_Nodes.push_back({}); + m_Nodes.push_back({}); + tasks.push_back({ node.first, task.first, leftCount }); + tasks.push_back({ node.first + 1, task.first + leftCount, task.count - leftCount }); + } + } + + std::vector m_Nodes; + std::vector m_Triangles; + }; + + // LU Toolbox's ground plane: a box 1000 x 1000 x 100 whose top is at LDD y 0, black (a path hitting it ends) + float GroundHit(const glm::vec3& origin, const glm::vec3& direction, float maxT) { + static const glm::vec3 MIN(-500.0f, -100.0f, -500.0f), MAX(500.0f, 0.0f, 500.0f); + float enter = 0.0f, exit = maxT; + for (int axis = 0; axis < 3; axis++) { + if (std::abs(direction[axis]) < 1e-20f) { + if (origin[axis] < MIN[axis] || origin[axis] > MAX[axis]) return INF; + continue; + } + const float inv = 1.0f / direction[axis]; + float t0 = (MIN[axis] - origin[axis]) * inv, t1 = (MAX[axis] - origin[axis]) * inv; + if (t0 > t1) std::swap(t0, t1); + enter = std::max(enter, t0); + exit = std::min(exit, t1); + if (enter > exit) return INF; + } + return enter; + } + + class Tracer { + public: + Tracer(const UgcModel::Mesh& mesh, const UgcHsr::Options& options) : m_Mesh(mesh), m_Bvh(mesh), m_Options(options) { + m_Smooth = mesh.normals.size() == mesh.positions.size(); + } + + // The triangle's normal from its winding (unit; zero when it has no area) + glm::vec3 FaceNormal(uint32_t t) const { + const auto& a = m_Mesh.positions[m_Mesh.indices[t * 3]]; + const auto n = glm::cross(m_Mesh.positions[m_Mesh.indices[t * 3 + 1]] - a, m_Mesh.positions[m_Mesh.indices[t * 3 + 2]] - a); + const float length = glm::length(n); + return length > 0.0f ? n / length : glm::vec3(0.0f); + } + + // The vertex normals at barycentric w (Cycles' smooth normal: the face's own when they sum to nothing) + glm::vec3 ShadingNormal(uint32_t t, const glm::vec3& w, const glm::vec3& faceNormal) const { + if (!m_Smooth) return faceNormal; + const auto n = m_Mesh.normals[m_Mesh.indices[t * 3]] * w.x + m_Mesh.normals[m_Mesh.indices[t * 3 + 1]] * w.y + m_Mesh.normals[m_Mesh.indices[t * 3 + 2]] * w.z; + const float length = glm::length(n); + return length > 0.0f ? n / length : faceNormal; + } + + glm::vec3 Position(uint32_t t, const glm::vec3& w) const { + return m_Mesh.positions[m_Mesh.indices[t * 3]] * w.x + m_Mesh.positions[m_Mesh.indices[t * 3 + 1]] * w.y + m_Mesh.positions[m_Mesh.indices[t * 3 + 2]] * w.z; + } + + /** + * One path from point w (barycentric) of triangle t, as a Cycles diffuse bake traces it: whether it reaches the + * sky. The path bounces off the model in the directions the bake material draws until a bounce ray hits + * nothing, which is the sky. The sky isn't sampled directly: Cycles doesn't sample a world of one flat color as + * a light, so only rays that happen to leave count. Up to `bounces` bounces (4 of them glossy), and from the + * second bounce on the path may end early (Cycles' Russian roulette: it goes on with probability + * sqrt(throughput)). + */ + bool Escapes(uint32_t t, const glm::vec3& w, Random& random) const { + glm::vec3 ng = FaceNormal(t); + // The bake looks at the point along its smooth normal (the ray comes from there): that side is lit, and + // when the winding faces the other way Cycles treats it as the back of the face (both normals turned) + glm::vec3 n = ShadingNormal(t, w, ng); + glm::vec3 incoming = n; + if (glm::dot(ng, incoming) < 0.0f) { + ng = -ng; + n = -n; + } + glm::vec3 p = Position(t, w); + uint32_t self = t; + float throughput = 1.0f; + float minRayPdf = INF; + int glossy = 0; + for (int bounce = 0;; bounce++) { + // The material's normal: the Principled BSDF keeps its mirror reflection above the surface + const Principled material(EnsureValidReflection(ng, incoming, n), incoming, ng, n, bounce == 0, minRayPdf); + const auto sample = material.Draw(random); + // Nothing sampled (below the surface): the path ends + if (!(sample.pdf > 0.0f) || !(sample.throughput > 0.0f)) return false; + const auto& direction = sample.direction; + throughput *= sample.throughput; + minRayPdf = std::min(minRayPdf, sample.pdf); + const auto hit = m_Bvh.Closest(p, direction, self); + if (m_Options.groundPlane && GroundHit(p, direction, hit.t) < hit.t) return false; + if (hit.triangle == UINT32_MAX) return true; + // Past the bounce limits the next surface doesn't scatter (Cycles: Max Bounces, Glossy 4) + if (bounce + 1 > m_Options.bounces) return false; + if (sample.glossy && ++glossy > GLOSSY_BOUNCES) return false; + if (bounce + 1 >= 2) { + const float probability = std::min(std::sqrt(throughput), 1.0f); + if (random.Float() >= probability) return false; + throughput /= probability; + } + self = hit.triangle; + ng = FaceNormal(self); + n = ShadingNormal(self, glm::vec3(1.0f - hit.u - hit.v, hit.u, hit.v), ng); + incoming = -direction; + // Hit from behind: the back is lit (both normals turned towards the ray) + if (glm::dot(ng, incoming) < 0.0f) { + ng = -ng; + n = -n; + } + p = p + direction * hit.t; + } + } + + private: + const UgcModel::Mesh& m_Mesh; + Bvh m_Bvh; + const UgcHsr::Options& m_Options; + bool m_Smooth{}; + }; +} + +namespace UgcHsr { + std::vector SamplePoints(const glm::vec3& a, const glm::vec3& b, const glm::vec3& c, float spacing, size_t minimum) { + // Too small to lay out: the centre and one point towards each corner (the centres of its four halved-side + // triangles) + const std::vector fallback{ { 1.0f / 3, 1.0f / 3, 1.0f / 3 }, { 2.0f / 3, 1.0f / 6, 1.0f / 6 }, { 1.0f / 6, 2.0f / 3, 1.0f / 6 }, + { 1.0f / 6, 1.0f / 6, 2.0f / 3 } }; + const std::array corners{ a, b, c }; + // The longest side from corner `first` to the next; the third is the apex + int first = 0; + float longest = -1.0f; + for (int i = 0; i < 3; i++) { + const float length = glm::length(corners[(i + 1) % 3] - corners[i]); + if (length > longest) { + longest = length; + first = i; + } + } + const float area = 0.5f * glm::length(glm::cross(b - a, c - a)); + if (!(longest > 0.0f) || !(area > 0.0f) || !(spacing > 0.0f) || !std::isfinite(area)) return fallback; + minimum = std::min(minimum, MAX_POINTS); + // Very big triangles: points further apart, at most MAX_POINTS + spacing = std::max(spacing, std::sqrt(area / static_cast(MAX_POINTS))); + const float height = 2.0f * area / longest; + std::vector points; + for (int attempt = 0; attempt < 16; attempt++) { + points.clear(); + const int along = std::max(1, static_cast(std::ceil(longest / spacing))); + const int rows = std::max(1, static_cast(std::ceil(height / spacing))); + for (int row = 0; row < rows; row++) { + // v: from the longest side (0) to the apex (1); the row is (1 - v) of the side's length + const float v = (row + 0.5f) / rows; + const int count = std::max(1, static_cast(std::lround(along * (1.0f - v)))); + for (int j = 0; j < count; j++) { + const float u = (j + 0.5f) / count; + glm::vec3 w{}; + w[first] = (1.0f - u) * (1.0f - v); + w[(first + 1) % 3] = u * (1.0f - v); + w[(first + 2) % 3] = v; + points.push_back(w); + } + } + if (points.size() >= minimum) break; + // Fewer than the minimum: closer together + spacing *= 0.95f * std::sqrt(static_cast(points.size()) / static_cast(minimum)); + } + return points.size() < fallback.size() ? fallback : points; + } + + std::vector Visible(const UgcModel::Mesh& mesh, const Options& options, uint64_t* pointCount, uint64_t* pathCount) { + const size_t triangles = mesh.TriangleCount(); + std::vector visible(triangles, true); + if (triangles == 0) return visible; + const Tracer tracer(mesh, options); + const int samples = std::max(options.samples, 1); + uint64_t points = 0, paths = 0, sinceCheckpoint = 0; + for (uint32_t t = 0; t < triangles; t++) { + const auto& a = mesh.positions[mesh.indices[t * 3]]; + const auto& b = mesh.positions[mesh.indices[t * 3 + 1]]; + const auto& c = mesh.positions[mesh.indices[t * 3 + 2]]; + // A triangle without area draws nothing (LU Toolbox's bake leaves it dark too): removed + if (tracer.FaceNormal(t) == glm::vec3(0.0f)) { + visible[t] = false; + continue; + } + const auto weights = SamplePoints(a, b, c, options.spacing, static_cast(std::max(options.minPoints, 1))); + points += weights.size(); + bool escaped = false; + // A path from each point, then another from each, ...: a triangle that's seen is usually known at once + for (int sample = 0; sample < samples && !escaped; sample++) { + for (size_t i = 0; i < weights.size(); i++) { + Random random(PathSeed(options.seed, t, i, static_cast(sample))); + paths++; + if (tracer.Escapes(t, weights[i], random)) { + escaped = true; + break; + } + } + sinceCheckpoint += weights.size(); + if (sinceCheckpoint >= 256) { + UgcThrottle::Checkpoint(); + sinceCheckpoint = 0; + } + } + visible[t] = escaped; + } + if (pointCount) *pointCount = points; + if (pathCount) *pathCount = paths; + return visible; + } + + Result RemoveHiddenFaces(UgcModel::Model& model, const Options& options) { + Result result; + auto& opaque = model.opaque; + result.trianglesBefore = opaque.TriangleCount() + model.transparent.TriangleCount(); + if (opaque.Empty() || !options.enabled) return result; + result.kept = Visible(opaque, options, &result.points, &result.paths); + for (const bool kept : result.kept) result.trianglesRemoved += kept ? 0 : 1; + UgcModel::KeepTriangles(opaque, result.kept); + return result; + } +} diff --git a/dUgcServer/UgcHsr.h b/dUgcServer/UgcHsr.h new file mode 100644 index 000000000..d7b525012 --- /dev/null +++ b/dUgcServer/UgcHsr.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +#include + +#include "UgcModel.h" + +/** + * Hidden surface removal as LU Toolbox's Remove Hidden Faces decides it, without its texture: paths are traced from + * points on each opaque triangle under a sky of overwhelming brightness, bouncing off the model; a triangle none of + * whose paths reaches the sky is removed. So faces seen only through openings, or lit only by light bounced in + * (interiors, recesses), stay; faces sealed inside go. docs/UgcServer.md ("Hidden faces") has the details. + */ +namespace UgcHsr { + struct Options { + bool enabled{ true }; // remove_hidden_faces + bool groundPlane{ false }; // hsr_ground_plane: LU Toolbox's black box under the model (y 0 down to -100) + int samples{ 8 }; // hsr_samples: paths traced from each point (LU Toolbox's Samples) + int bounces{ 8 }; // hsr_bounces: bounces a path may take (the Cycles bake's Max Bounces) + float spacing{ 0.1143f }; // hsr_sample_spacing: LDD units between points (a stud is 0.8: 7 points along it) + int minPoints{ 28 }; // hsr_min_points: points on a triangle at least (LU Toolbox bakes 28 texels a triangle) + uint64_t seed{}; // of the paths' random numbers (the same seed gives the same result) + }; + + struct Result { + size_t trianglesBefore{}; // opaque and transparent + size_t trianglesRemoved{}; + std::vector kept; // per opaque triangle before: whether it stayed + uint64_t points{}; // sample points traced from + uint64_t paths{}; // paths traced + }; + + /** + * Which triangles of `mesh` a path from them reaches the sky from (triangles without area: none). `mesh` is + * everything that occludes (the opaque bricks; transparent ones hide nothing, as in LU Toolbox). + */ + std::vector Visible(const UgcModel::Mesh& mesh, const Options& options, uint64_t* points = nullptr, uint64_t* paths = nullptr); + + // Removes the opaque triangles that aren't Visible (nothing when options.enabled is off); transparent ones stay + Result RemoveHiddenFaces(UgcModel::Model& model, const Options& options); + + /** + * The points on triangle a, b, c paths start from, as barycentric weights (of a, b, c): rows parallel to its + * longest side, `spacing` apart, with points `spacing` apart along each row (so about area / spacing^2 of them). + * When that gives fewer than `minimum` the rows and points are closer together until there are that many; at + * least 4 (its centre and one towards each corner), at most 4096 (big triangles get them further apart). + */ + std::vector SamplePoints(const glm::vec3& a, const glm::vec3& b, const glm::vec3& c, float spacing, size_t minimum = 4); +} diff --git a/dUgcServer/UgcJobs.cpp b/dUgcServer/UgcJobs.cpp index 6139ef489..bde8d9b21 100644 --- a/dUgcServer/UgcJobs.cpp +++ b/dUgcServer/UgcJobs.cpp @@ -52,12 +52,12 @@ namespace UgcJobs { } uint64_t EstimateMemory(size_t parts, const Settings& settings) { - // Measured: the renders' buffers, and per brick its mesh in each LOD (positions, normals, colors, indices, - // the occlusion tree and copies made along the way), about 40 KB at LOD 0 - const uint64_t resolution = static_cast(std::clamp(settings.optimize.resolution, 64, 4096)); + // Measured: the icon's buffers, and per brick its mesh in each LOD (positions, normals, colors, indices, + // the occlusion tree and copies made along the way), about 40 KB at LOD 0, and the hidden faces' ray tree + // (about 60 bytes a triangle, one LOD at a time) const uint64_t icon = static_cast(settings.icon.size) * settings.icon.supersample; - const uint64_t fixed = resolution * resolution * 8 + icon * icon * 20 + 1024 * 1024 * 4 + 16 * 1024 * 1024; - return fixed + static_cast(parts) * 40 * 1024 * (1 + settings.lods.size()); + const uint64_t fixed = icon * icon * 20 + 1024 * 1024 * 4 + 16 * 1024 * 1024; + return fixed + static_cast(parts) * (40 * 1024 * (1 + settings.lods.size()) + 16 * 1024); } namespace { @@ -212,7 +212,9 @@ namespace UgcJobs { nlohmann::json entry{ { "lod", lods[i] }, { "near", ranges[i].first }, { "far", ranges[i].second }, { "opaqueBefore", model.opaque.TriangleCount() }, { "transparent", model.transparent.TriangleCount() } }; step = std::chrono::steady_clock::now(); - const auto optimized = UgcRender::Optimize(model, settings.optimize); + auto hsr = settings.hsr; + hsr.seed = seed; + const auto optimized = UgcHsr::RemoveHiddenFaces(model, hsr); hsrMs += Since(step); if (i == 0 && optimized.trianglesRemoved > 0) { if (!outcome.note.empty()) outcome.note += "; "; @@ -303,7 +305,8 @@ namespace UgcJobs { { "icon", std::lround(iconMs) }, { "total", std::lround(Since(started)) } }; stats["settings"] = { { "palette", settings.build.palette == UgcModel::ePalette::LU_TOOLBOX ? "lu_toolbox" : "brickdb" }, { "colorVariation", settings.build.colorVariation }, { "transparentOpacity", settings.build.transparentOpacity }, - { "removeHiddenFaces", settings.optimize.removeHidden }, { "groundPlane", settings.optimize.groundPlane }, + { "removeHiddenFaces", settings.hsr.enabled }, { "groundPlane", settings.hsr.groundPlane }, { "hsrSamples", settings.hsr.samples }, + { "hsrBounces", settings.hsr.bounces }, { "hsrSampleSpacing", settings.hsr.spacing }, { "hsrMinPoints", settings.hsr.minPoints }, { "ao", settings.ao.enabled }, { "aoDistance", settings.ao.distance }, { "aoSamples", settings.ao.samples }, { "aoStrength", settings.ao.strength } }; outcome.stats = stats.dump(); outcome.files["stats.json"] = outcome.stats; diff --git a/dUgcServer/UgcJobs.h b/dUgcServer/UgcJobs.h index 9bdbc50bb..5b874a333 100644 --- a/dUgcServer/UgcJobs.h +++ b/dUgcServer/UgcJobs.h @@ -9,6 +9,7 @@ #include #include "UgcBricks.h" +#include "UgcHsr.h" #include "UgcIconParams.h" #include "UgcRender.h" #include "UgcStorage.h" @@ -50,7 +51,7 @@ namespace UgcJobs { std::string shaderOpaque{ "01" }; // S_Opaque_...; transparent shapes are always S01 bool combineTransparent{ false }; // one shape for all transparent bricks, else one per brick (Combine Transparent) Shaders shaders; // metal and glow groups (all off by default) - UgcRender::OptimizeOptions optimize; // hidden surface removal + UgcHsr::Options hsr; // hidden surface removal (Remove Hidden Faces) UgcRender::AoOptions ao; // Bake Lighting (AO Only) UgcRender::IconOptions icon; // from the icon_* settings (UgcIconParams); presets and overrides go over it uint32_t maxBricks{}; // a model with more fails; 0: no limit diff --git a/dUgcServer/UgcRender.cpp b/dUgcServer/UgcRender.cpp index 37b636022..8b2bcf4a1 100644 --- a/dUgcServer/UgcRender.cpp +++ b/dUgcServer/UgcRender.cpp @@ -213,116 +213,9 @@ namespace { float PixelSize() const { return 2.0f * radius / resolution; } }; - - void Bounds(const UgcModel::Model& model, glm::vec3& center, float& radius) { - glm::vec3 min{}, max{}; - if (!model.Bounds(min, max)) { - center = glm::vec3(0.0f); - radius = 1.0f; - return; - } - center = (min + max) * 0.5f; - radius = std::max(glm::length(max - min) * 0.5f, 0.01f); - } } namespace UgcRender { - std::vector SphereDirections() { - const float t = (1.0f + std::sqrt(5.0f)) / 2.0f; - const std::vector corners = { - { -1, t, 0 }, { 1, t, 0 }, { -1, -t, 0 }, { 1, -t, 0 }, - { 0, -1, t }, { 0, 1, t }, { 0, -1, -t }, { 0, 1, -t }, - { t, 0, -1 }, { t, 0, 1 }, { -t, 0, -1 }, { -t, 0, 1 }, - }; - std::vector directions; - for (const auto& corner : corners) directions.push_back(glm::normalize(corner)); - // Edge centres: the pairs of corners that are neighbours (the shortest distance apart) - const float edge = glm::length(corners[0] - corners[1]); - for (size_t i = 0; i < corners.size(); i++) { - for (size_t j = i + 1; j < corners.size(); j++) { - if (std::abs(glm::length(corners[i] - corners[j]) - edge) < 1e-3f) directions.push_back(glm::normalize(corners[i] + corners[j])); - } - } - return directions; - } - - OptimizeResult Optimize(UgcModel::Model& model, const OptimizeOptions& options) { - OptimizeResult result; - auto& opaque = model.opaque; - result.trianglesBefore = opaque.TriangleCount() + model.transparent.TriangleCount(); - if (opaque.Empty() || !options.removeHidden) return result; - - glm::vec3 center{}; - float radius{}; - Bounds(model, center, radius); - radius *= 1.02f; - const int resolution = std::clamp(options.resolution, 64, 4096); - const size_t pixels = static_cast(resolution) * resolution; - std::vector depth(pixels); - std::vector ids(pixels); - std::vector screen(opaque.positions.size()); - - const size_t triangles = opaque.TriangleCount(); - std::vector visible(triangles, false); - std::vector facing(triangles, true); - std::vector faceNormals(triangles, glm::vec3(0.0f)); - for (size_t t = 0; t < triangles; t++) { - if (opaque.normals.size() != opaque.positions.size()) break; - faceNormals[t] = glm::normalize(opaque.normals[opaque.indices[t * 3]] + opaque.normals[opaque.indices[t * 3 + 1]] + opaque.normals[opaque.indices[t * 3 + 2]] + glm::vec3(1e-6f)); - } - // Without normals nothing is culled - if (opaque.normals.size() != opaque.positions.size()) std::fill(faceNormals.begin(), faceNormals.end(), glm::vec3(0.0f)); - - for (const auto& direction : SphereDirections()) { - // A ground plane under the model hides everything from below - if (options.groundPlane && direction.y < -0.05f) continue; - UgcThrottle::Checkpoint(); - const OrthoView view(center, radius, direction, resolution); - const float bias = view.PixelSize(); - std::fill(depth.begin(), depth.end(), INF); - std::fill(ids.begin(), ids.end(), 0); - for (size_t v = 0; v < opaque.positions.size(); v++) screen[v] = view.Project(opaque.positions[v]); - // Faces turned away can't be seen from here (they're seen from the directions they face); their vertex - // normals say which way they face, which doesn't depend on the files' winding - for (size_t t = 0; t < triangles; t++) facing[t] = glm::dot(faceNormals[t], direction) > -0.1f; - for (size_t t = 0; t < triangles; t++) { - if (!facing[t]) continue; - if ((t & 0x3FFF) == 0) UgcThrottle::Checkpoint(); - const auto id = static_cast(t + 1); - Rasterize(resolution, resolution, screen[opaque.indices[t * 3]], screen[opaque.indices[t * 3 + 1]], screen[opaque.indices[t * 3 + 2]], - [&](int x, int y, float z, float, float, float) { - auto& stored = depth[static_cast(y) * resolution + x]; - if (z < stored) { - stored = z; - ids[static_cast(y) * resolution + x] = id; - } - }); - } - for (const auto id : ids) { - if (id != 0) visible[id - 1] = true; - } - - // Triangles too small or thin to cover a pixel centre: kept when their centre isn't behind what was drawn. - // Bigger ones that show would have covered one. - const float smallArea = 2.0f; // pixels - for (size_t t = 0; t < triangles; t++) { - if (visible[t] || !facing[t]) continue; - const auto& a = screen[opaque.indices[t * 3]]; - const auto& b = screen[opaque.indices[t * 3 + 1]]; - const auto& c = screen[opaque.indices[t * 3 + 2]]; - if (std::abs(Edge(a, b, c.x, c.y)) * 0.5f > smallArea) continue; - const auto centre = (a + b + c) / 3.0f; - const int x = static_cast(centre.x), y = static_cast(centre.y); - if (x < 0 || y < 0 || x >= resolution || y >= resolution || centre.z <= depth[static_cast(y) * resolution + x] + bias) visible[t] = true; - } - } - - for (size_t t = 0; t < triangles; t++) result.trianglesRemoved += visible[t] ? 0 : 1; - result.kept = visible; - UgcModel::KeepTriangles(opaque, visible); - return result; - } - std::vector AmbientOcclusion(const UgcModel::Mesh& mesh, const UgcModel::Mesh& occluders, float distance, int samples) { std::vector ao(mesh.positions.size(), 1.0f); if (occluders.Empty() || samples <= 0 || distance <= 0.0f || mesh.normals.size() != mesh.positions.size()) return ao; diff --git a/dUgcServer/UgcRender.h b/dUgcServer/UgcRender.h index 5246f55ca..6dd6c4535 100644 --- a/dUgcServer/UgcRender.h +++ b/dUgcServer/UgcRender.h @@ -9,8 +9,8 @@ #include "UgcModel.h" /** - * A small software rasterizer (no GPU or display needed) for what the UGC server draws: the icons, and the renders - * from many directions that find the faces nobody can see and bake ambient occlusion into the vertex colors. + * A small software rasterizer (no GPU or display needed) for what the UGC server draws: the icons, and the occlusion + * rays that bake ambient occlusion into the vertex colors. */ namespace UgcRender { // 8-bit RGBA, rows top to bottom, not premultiplied @@ -69,25 +69,6 @@ namespace UgcRender { // transparent) get the glitter texture's white flecks over their color before the light (LEGO-AnimUV), still. Image RenderIcon(const UgcModel::Model& model, const IconOptions& options, const std::vector* opaqueAo = nullptr); - struct OptimizeOptions { - int resolution{ 1024 }; // of each direction's render - bool removeHidden{ true }; - bool groundPlane{ false }; // LU Toolbox's Use Ground Plane: nothing is seen from below the model - }; - - struct OptimizeResult { - size_t trianglesBefore{}; - size_t trianglesRemoved{}; - std::vector kept; // per opaque triangle before: whether it stayed (for UgcModel::KeepTriangles) - }; - - /** - * Hidden surface removal: renders the opaque mesh from 42 directions around it and removes the triangles that show - * in none of them (with a conservative test, so small visible ones stay). Transparent bricks hide nothing and - * aren't touched, as in LU Toolbox. - */ - OptimizeResult Optimize(UgcModel::Model& model, const OptimizeOptions& options); - /** * Ambient occlusion of each vertex of `mesh`: the share of `samples` rays (cosine weighted around the vertex * normal, the same pattern every time) that leave without hitting a triangle of `occluders` within `distance`. @@ -100,7 +81,4 @@ namespace UgcRender { * while baking and not baked) plus the glow colors, multiplied into the vertex colors. */ std::vector BakeAo(UgcModel::Model& model, const AoOptions& options); // the occlusion used, per opaque vertex - - // The 42 directions Optimize renders from (an icosahedron's corners and edge centres), unit length - std::vector SphereDirections(); } diff --git a/dUgcServer/UgcServer.cpp b/dUgcServer/UgcServer.cpp index 2c84ef808..adaee0abf 100644 --- a/dUgcServer/UgcServer.cpp +++ b/dUgcServer/UgcServer.cpp @@ -144,9 +144,12 @@ namespace { settings.build.satinColors = ColorList("satin_colors", "360,362,363,364,365,366,367,376"); settings.build.satinOpacity = std::clamp(Setting("satin_opacity", 75.0f), 0.0f, 100.0f); settings.build.satinWhiten = std::clamp(Setting("satin_whiten", 20.0f), 0.0f, 100.0f); - settings.optimize.removeHidden = Setting("remove_hidden_faces", 1) != 0; - settings.optimize.groundPlane = Setting("hsr_ground_plane", 0) != 0; - settings.optimize.resolution = Setting("optimize_resolution", 1024); + settings.hsr.enabled = Setting("remove_hidden_faces", 1) != 0; + settings.hsr.groundPlane = Setting("hsr_ground_plane", 0) != 0; + settings.hsr.samples = std::clamp(Setting("hsr_samples", 8), 1, 256); + settings.hsr.bounces = std::clamp(Setting("hsr_bounces", 8), 0, 64); + settings.hsr.spacing = std::clamp(Setting("hsr_sample_spacing", 0.1143f), 0.01f, 10.0f); + settings.hsr.minPoints = std::clamp(Setting("hsr_min_points", 28), 1, 4096); settings.ao.enabled = Setting("bake_ao", 1) != 0; settings.ao.distance = Setting("ao_distance", 5.0f); settings.ao.samples = std::clamp(Setting("ao_samples", 64), 1, 1024); diff --git a/docs/UgcServer.md b/docs/UgcServer.md index 2d9b3975b..74aa11a83 100644 --- a/docs/UgcServer.md +++ b/docs/UgcServer.md @@ -135,9 +135,10 @@ and the notices (see Threads). Cars and rockets are made once per combination of 0.025) either way, times the color's own amount (black 0.4, orange 1.5, ...), clamped and taken back; hue and saturation stay. The random number comes from the model's id, the brick's index and the material, so making a model again gives the same colors, and every LOD the same (LU Toolbox restarts its random sequence per LOD). -4. Faces nobody can see are removed from the opaque bricks (they're rendered from 42 directions and triangles that - never show are dropped; transparent bricks hide nothing and aren't touched). `hsr_ground_plane=1` also drops what - can only be seen from below. +4. Faces nobody can see are removed from the opaque bricks as LU Toolbox's Remove Hidden Faces decides it: paths are + traced from points on each triangle, bouncing off the model, and a triangle none of whose paths reaches the sky is + removed (see "Hidden faces" below). Transparent bricks hide nothing and aren't touched. `hsr_ground_plane=1` also + drops what can only be seen from below. 5. Ambient occlusion is baked like LU Toolbox's Bake Lighting with AO Only: 64 rays per vertex (`ao_samples`) over the hemisphere around the vertex normal (cosine weighted, the same pattern every time, so a model made again comes out the same), each blocked when it hits an opaque triangle within 5 (`ao_distance`); the vertex's occlusion is the @@ -209,7 +210,7 @@ and the notices (see Threads). Cars and rockets are made once per combination of | Transparent Opacity 58.82% | Same (`transparent_opacity`) | | Vertex color layers Col, Lit, Alpha (1), Glow | One color set: Col times Lit (glow added to Lit); alpha is the opacity | | Setup Bake Material (VertexColor / VertexColorTransparent) | Equivalent: white NiMaterialProperty, vertex colors as ambient and diffuse, NiAlphaProperty on transparent shapes | -| Remove Hidden Faces: Cycles bakes with an overexposed world (VC pre-pass 32 samples, tris to quads, 5 pixels between vertices, 8 samples, threshold 0.01), autoremove, transparent bricks hidden | Same result by other means: depth renders from 42 directions (`optimize_resolution`), transparent bricks hidden and untouched; the pre-pass, quads and samples are details of Blender's baking | +| Remove Hidden Faces: Cycles bakes with an overexposed world (VC pre-pass 32 samples, tris to quads, 5 pixels between vertices, 8 samples, threshold 0.01), autoremove, transparent bricks hidden | The same paths traced directly from points on the triangles, no texture (`hsr_samples` 8, `hsr_bounces` 8, `hsr_min_points` 28, `hsr_sample_spacing`); decided per triangle, not per joined quad; no VC pre-pass (see "Hidden faces") | | Use Ground Plane off | Same (`hsr_ground_plane=0`) | | Split objects over 65536 vertices (divide_mesh, along the longest side, linked parts together) | Same, also keeping each shape under 65535 triangles (the format's limit) | | Setup LOD data: SceneNode, NiLODNode per shape name, LOD nodes, near/far by the levels there are, `S01_Opaque_`/`S01_Alpha_` names cut at 60 | Same (`lod_distance_0..3`, `lod_cull`, `shader_opaque`); LU Toolbox's glow, metal and superemissive shader settings are unused by it too; the UGC server's own metal and glow groups are opt in (see below) | @@ -222,6 +223,56 @@ and the notices (see Threads). Cars and rockets are made once per combination of | Icon scene BrickBuild / Car: 50 mm lens, camera 53.4 / 19.5 degrees, sun at 21 / 50.3, 128 px, framing 1.03, transparent film | Same framing (`icon_*`); the light is brighter, to match the game's icons | | Icon scene Rocket: 35 mm lens, other angles, two suns | Not built in; a preset for the rocket build type can be set in the icon editor | +### Hidden faces + +`UgcHsr::RemoveHiddenFaces` (`remove_hidden_faces=1`, default) traces the paths LU Toolbox's Remove Hidden Faces +bakes, directly from points on the triangles, without its UV layout and texture. Each LOD's opaque mesh is done on its +own, on the job's worker thread. + +* What blocks: the LOD's opaque triangles (LU Toolbox hides the transparent bricks while it bakes). With + `hsr_ground_plane=1`, LU Toolbox's ground plane: a black box 1000 x 1000 x 100 whose top is at LDD y 0; a path that + hits it ends. +* Points: on each triangle, rows parallel to its longest side `hsr_sample_spacing` apart (default 0.1143 LDD units, 7 + x 7 points on a stud-sized square), with points the same distance apart along each row. A triangle that gets fewer + than `hsr_min_points` (default 28, the texels LU Toolbox bakes for every triangle) gets its rows and points closer + together until it has that many; at most 4096 (bigger triangles get them further apart). Triangles without area + draw nothing and are removed (LU Toolbox's bake leaves them dark too). +* Paths: `hsr_samples` (default 8, LU Toolbox's Samples) from each point, each as Cycles 3.1 traces LU Toolbox's + diffuse bake: the point is looked at along its vertex normal (the side it faces is baked; a triangle wound the other + way is baked from its back, as Cycles does); the bake material (a new material's Principled BSDF: base color 0.8, + specular 0.5, roughness 0.5, GGX) picks its diffuse part (Burley) or its specular part (GGX, Fresnel of IOR 1.5) by + Cycles' sample weights (at the first point each weight is raised to an eighth of their sum; the specular part is + blurred by Filter Glossy 1 after the first bounce) and the path bounces in the direction drawn. A bounce ray that + hits nothing has reached the sky. A triangle hit from behind is lit on its back. At most `hsr_bounces` (default 8, the bake's Max Bounces) bounces, 4 of them + glossy; from the second bounce on the path goes on with probability sqrt(throughput) (Russian roulette). The sky + isn't sampled directly: Cycles doesn't sample a world of one flat color as a light, so only rays leaving the model + count. +* Decision: a triangle is kept as soon as one of its paths reaches the sky, removed when none does. LU Toolbox's + threshold (0.01 of the baked average) means the same: its world's strength 100000 saturates every texel a path + reaches. +* The same every time: each path's random numbers come from the model's id, the triangle, the point and the sample. +* Differences from LU Toolbox: triangles are decided on their own. LU Toolbox joins triangles into quads first (Tris + to Quads) and keeps both halves of a quad when either is seen, so it keeps some triangles this removes. Big triangles + get more points than LU Toolbox's fixed 7 x 7 texels. The VC pre-pass isn't done (below). +* Cost: the paths from triangles that are seen stop at the first that leaves, so the time goes to the hidden ones + (`hsr_min_points` x `hsr_samples` paths each at least). `hsr_min_points` and `hsr_samples` trade time for how + reliably faces only just seen are kept. The long loop calls UgcThrottle's checkpoint. Measured (one thread, CPU + time, LOD 0): 6 s for 69 bricks, 74 to 86 s for 250 to 330 bricks, 129 s for 883 bricks (the 42 depth renders used + before took 1 to 4 s; LU Toolbox 3, 24 to 29 and 91 s on 8 threads). + +Checked against LU Toolbox's operator in Blender 3.1.2 on the same meshes (VC pre-pass off, Tris to Quads off so it +also decides per triangle; 27 models: a closed box, a house with a doorway, a room with a window, a tube, a cup and +stacked plates built for it, 5 test LXFMLs and 16 player models of 1 to 883 bricks, 937,814 triangles): it removed +501,362 triangles and the UGC server 501,172, with 9,370 only there and 9,180 only here, per model as far apart as +LU Toolbox is from itself with another random seed. With the ground plane the same (5 models). The depth renders used +before removed 191,900 triangles LU Toolbox keeps (faces reached only by bounced light). + +LU Toolbox's VC pre-pass (on by default there, not done here): before the texture bake it bakes vertex colors (32 +samples, 12 bounces) and leaves out of the texture bake every face with a corner above the threshold. Those faces are +kept without the texture bake's test, so with it LU Toolbox keeps somewhat more (for example 1865 instead of 2529 +triangles removed from a 61-brick model); it saves LU Toolbox baking texels for faces that are seen, which the paths +here already stop early for. + ### Metal and glow (on by default, not how live looked) Live's models, LU Toolbox's exports and the client's own builder (`LUNifBuilder_BK`, which writes only `S01_Opaque` @@ -574,7 +625,7 @@ of old items' `.sd0` icons and checksums and of builds' combination ids runs on Settings in `ugcconfig.ini` (and the dashboard's settings page), picked up while running when the config is reloaded: * `worker_threads` (restart): how many models are made at once. -* `max_cpu_percent`: the workers together average at most this share of all CPU cores. The long loops (the renders +* `max_cpu_percent`: the workers together average at most this share of all CPU cores. The long loops (the paths for hidden faces, the occlusion rays, icons) account each thread's CPU time every few milliseconds against a budget that fills at that rate and sleep while it's overdrawn (UgcThrottle), so it holds for long jobs too and whatever `worker_threads` is. Short bursts (a quarter second) aren't slowed. 0: no limit. diff --git a/resources/ugcconfig.ini b/resources/ugcconfig.ini index 7699c1495..866e3aae9 100644 --- a/resources/ugcconfig.ini +++ b/resources/ugcconfig.ini @@ -111,11 +111,17 @@ satin_colors=360,362,363,364,365,366,367,376 satin_opacity=75 satin_whiten=20 -# Remove faces that can't be seen from anywhere (optimize_resolution: detail of the renders that decide it; -# hsr_ground_plane: 1 also removes what can only be seen from below the model) +# Remove faces that can't be seen from anywhere, as LU Toolbox's Remove Hidden Faces: paths are traced from points on +# each opaque triangle, bouncing off the model; a triangle none of whose paths reaches the sky is removed. +# hsr_ground_plane: 1 also removes what can only be seen from below the model. hsr_samples: paths from each point. +# hsr_bounces: bounces a path may take. hsr_sample_spacing: distance between the points in LDD units (a stud is 0.8). +# hsr_min_points: points on a triangle at least, however small it is (LU Toolbox bakes 28 texels a triangle). remove_hidden_faces=1 hsr_ground_plane=0 -optimize_resolution=1024 +hsr_samples=8 +hsr_bounces=8 +hsr_sample_spacing=0.1143 +hsr_min_points=28 # Bake ambient occlusion into the vertex colors: rays per vertex, how far they look, strength 0 (none) to 1, and how # much glowing colors add diff --git a/tests/dUgcTests/UgcTests.cpp b/tests/dUgcTests/UgcTests.cpp index 5535a5ad9..5df92a6e7 100644 --- a/tests/dUgcTests/UgcTests.cpp +++ b/tests/dUgcTests/UgcTests.cpp @@ -223,7 +223,7 @@ TEST(UgcRender, RemovesWhatIsInsideAndDrawsIcons) { )", error); auto model = UgcModel::Build(parts, library); ASSERT_EQ(model.opaque.TriangleCount(), 24u); - const auto result = UgcRender::Optimize(model, UgcRender::OptimizeOptions{ 256, true }); + const auto result = UgcHsr::RemoveHiddenFaces(model, UgcHsr::Options{}); EXPECT_EQ(result.trianglesRemoved, 12u); EXPECT_EQ(model.opaque.TriangleCount(), 12u); EXPECT_EQ(model.opaque.positions.size(), 8u); @@ -233,7 +233,6 @@ TEST(UgcRender, RemovesWhatIsInsideAndDrawsIcons) { EXPECT_EQ(icon.rgba[3], 0); // a corner is background EXPECT_EQ(icon.rgba[(16 * 32 + 16) * 4 + 3], 255); // the middle is the box EXPECT_GT(icon.rgba[(16 * 32 + 16) * 4], icon.rgba[(16 * 32 + 16) * 4 + 1]); // red - EXPECT_EQ(UgcRender::SphereDirections().size(), 42u); } TEST(UgcFormats, NifReadsBack) { @@ -693,7 +692,6 @@ TEST(UgcThrottle, KeepsUnderTheBudget) { TEST(UgcJobs, MakesLodsStatsAndIcons) { UgcBricks::BrickLibrary library(MakeRes(), 0); UgcJobs::Settings settings; - settings.optimize.resolution = 128; settings.ao.samples = 8; settings.icon.size = 32; settings.icon.supersample = 1; @@ -1076,7 +1074,6 @@ namespace { UgcJobs::Settings SmallSettings() { UgcJobs::Settings settings; - settings.optimize.resolution = 128; settings.ao.samples = 8; settings.icon.size = 32; settings.icon.supersample = 1; @@ -1615,3 +1612,146 @@ TEST(UgcModel, SatinColors) { // Satin's own group is the transparent one: no look EXPECT_TRUE(satin.transparent.looks.empty()); } + +TEST(UgcHsr, OffIsByteIdenticalToBefore) { + // remove_hidden_faces=0 makes exactly the files made before the path traced hidden faces replaced the renders. + // The hashes were taken with GCC on x86-64 Linux. + UgcBricks::BrickLibrary library(MakeRes(), 0); + auto settings = SmallSettings(); + settings.hsr.enabled = false; + const auto outcome = UgcJobs::ProcessModel(LOOKS_LXFML, library, settings, 7); + ASSERT_TRUE(outcome.ok) << outcome.error; + const auto other = UgcJobs::ProcessModel(LXFML5, library, settings, 99); + ASSERT_TRUE(other.ok) << other.error; +#if defined(__linux__) && defined(__x86_64__) && defined(__GNUC__) && !defined(__clang__) + EXPECT_EQ(UgcFormats::Md5Hex(*ZCompression::Gunzip(outcome.files.at("model.nif.gz"))), "f91b88e46a92e9472710854902b25c9a"); + EXPECT_EQ(UgcFormats::Md5Hex(*ZCompression::Gunzip(outcome.files.at("model.noao.nif.gz"))), "58a779933695da04bf9b3459c8369528"); + EXPECT_EQ(UgcFormats::Md5Hex(outcome.files.at("icon.png")), "032ff7df236a636a4c609071d9b46181"); + EXPECT_EQ(UgcFormats::Md5Hex(*ZCompression::Gunzip(other.files.at("model.nif.gz"))), "7eddc020a3e4bf22ed1df0183b042ced"); +#endif +} + +namespace { + // A quad a b c d (in order around it) facing `normal`, as two triangles wound to face it + void AddQuad(UgcModel::Mesh& mesh, glm::vec3 a, glm::vec3 b, glm::vec3 c, glm::vec3 d, glm::vec3 normal) { + const auto base = static_cast(mesh.positions.size()); + for (const auto& p : { a, b, c, d }) { + mesh.positions.push_back(p); + mesh.normals.push_back(normal); + mesh.colors.push_back(glm::vec4(1.0f)); + } + if (glm::dot(glm::cross(b - a, c - a), normal) >= 0.0f) mesh.indices.insert(mesh.indices.end(), { base, base + 1, base + 2, base, base + 2, base + 3 }); + else mesh.indices.insert(mesh.indices.end(), { base, base + 2, base + 1, base, base + 3, base + 2 }); + } + + // An axis aligned box [min, max], faces outwards (or inwards) + void AddBox(UgcModel::Mesh& mesh, glm::vec3 lo, glm::vec3 hi, bool inwards = false) { + const float s = inwards ? -1.0f : 1.0f; + AddQuad(mesh, { lo.x, lo.y, lo.z }, { lo.x, hi.y, lo.z }, { lo.x, hi.y, hi.z }, { lo.x, lo.y, hi.z }, { -s, 0, 0 }); + AddQuad(mesh, { hi.x, lo.y, lo.z }, { hi.x, hi.y, lo.z }, { hi.x, hi.y, hi.z }, { hi.x, lo.y, hi.z }, { s, 0, 0 }); + AddQuad(mesh, { lo.x, lo.y, lo.z }, { hi.x, lo.y, lo.z }, { hi.x, lo.y, hi.z }, { lo.x, lo.y, hi.z }, { 0, -s, 0 }); + AddQuad(mesh, { lo.x, hi.y, lo.z }, { hi.x, hi.y, lo.z }, { hi.x, hi.y, hi.z }, { lo.x, hi.y, hi.z }, { 0, s, 0 }); + AddQuad(mesh, { lo.x, lo.y, lo.z }, { hi.x, lo.y, lo.z }, { hi.x, hi.y, lo.z }, { lo.x, hi.y, lo.z }, { 0, 0, -s }); + AddQuad(mesh, { lo.x, lo.y, hi.z }, { hi.x, lo.y, hi.z }, { hi.x, hi.y, hi.z }, { lo.x, hi.y, hi.z }, { 0, 0, s }); + } + + // A room [-2, 2]^3 seen from inside (its walls face inwards), with a doorway in its +Z wall unless `closed`, and a + // small box in the middle of it (triangles from 0 to 11, the room's after) + UgcModel::Mesh Room(bool closed) { + UgcModel::Mesh mesh; + AddBox(mesh, glm::vec3(-0.3f), glm::vec3(0.3f)); + const float w = 2.0f; + const glm::vec3 in(0, 0, -1); + AddQuad(mesh, { -w, -w, -w }, { w, -w, -w }, { w, w, -w }, { -w, w, -w }, { 0, 0, 1 }); + AddQuad(mesh, { -w, -w, -w }, { -w, w, -w }, { -w, w, w }, { -w, -w, w }, { 1, 0, 0 }); + AddQuad(mesh, { w, -w, -w }, { w, w, -w }, { w, w, w }, { w, -w, w }, { -1, 0, 0 }); + AddQuad(mesh, { -w, -w, -w }, { w, -w, -w }, { w, -w, w }, { -w, -w, w }, { 0, 1, 0 }); + AddQuad(mesh, { -w, w, -w }, { w, w, -w }, { w, w, w }, { -w, w, w }, { 0, -1, 0 }); + // The +Z wall around a doorway x -0.5..0.5, y -2..0 + AddQuad(mesh, { -w, -w, w }, { -0.5f, -w, w }, { -0.5f, w, w }, { -w, w, w }, in); + AddQuad(mesh, { 0.5f, -w, w }, { w, -w, w }, { w, w, w }, { 0.5f, w, w }, in); + AddQuad(mesh, { -0.5f, 0, w }, { 0.5f, 0, w }, { 0.5f, w, w }, { -0.5f, w, w }, in); + if (closed) AddQuad(mesh, { -0.5f, -w, w }, { 0.5f, -w, w }, { 0.5f, 0, w }, { -0.5f, 0, w }, in); + return mesh; + } +} + +TEST(UgcHsr, KeepsWhatIsSeenThroughADoorwayOrOnlyByBouncedLight) { + const auto open = UgcHsr::Visible(Room(false), UgcHsr::Options{}); + // Every face of the box in the room, the one turned away from the doorway too (only light bounced off the back + // wall reaches it), and every wall + for (size_t t = 0; t < open.size(); t++) EXPECT_TRUE(open[t]) << t; + const auto closed = UgcHsr::Visible(Room(true), UgcHsr::Options{}); + for (size_t t = 0; t < 12; t++) EXPECT_FALSE(closed[t]) << t; +} + +TEST(UgcHsr, IsTheSameEveryTime) { + auto options = UgcHsr::Options{}; + options.seed = 1234; + options.samples = 1; // few paths, so chance matters + options.bounces = 3; + const auto mesh = Room(false); + const auto first = UgcHsr::Visible(mesh, options); + EXPECT_EQ(UgcHsr::Visible(mesh, options), first); + uint64_t points = 0, paths = 0; + UgcHsr::Visible(mesh, options, &points, &paths); + EXPECT_GT(points, 0u); + EXPECT_GT(paths, 0u); +} + +TEST(UgcHsr, GroundPlaneHidesTheUnderside) { + UgcModel::Mesh mesh; + AddBox(mesh, glm::vec3(0.0f), glm::vec3(0.8f, 0.96f, 0.8f)); // a brick on LDD's floor + auto options = UgcHsr::Options{}; + const auto without = UgcHsr::Visible(mesh, options); + for (size_t t = 0; t < without.size(); t++) EXPECT_TRUE(without[t]) << t; + options.groundPlane = true; + const auto with = UgcHsr::Visible(mesh, options); + for (size_t t = 0; t < with.size(); t++) EXPECT_EQ(with[t], t != 4 && t != 5) << t; // triangles 4 and 5: the bottom +} + +TEST(UgcHsr, RemovesTrianglesWithoutArea) { + UgcModel::Mesh mesh; + AddBox(mesh, glm::vec3(0.0f), glm::vec3(0.8f)); + const auto base = static_cast(mesh.positions.size()); + for (int i = 0; i < 3; i++) { + mesh.positions.push_back(glm::vec3(5.0f)); + mesh.normals.push_back(glm::vec3(0, 1, 0)); + } + mesh.indices.insert(mesh.indices.end(), { base, base + 1, base + 2 }); + const auto visible = UgcHsr::Visible(mesh, UgcHsr::Options{}); + ASSERT_EQ(visible.size(), 13u); + for (size_t t = 0; t < 12; t++) EXPECT_TRUE(visible[t]) << t; + EXPECT_FALSE(visible[12]); +} + +TEST(UgcHsr, SamplePointsFollowTheTrianglesSize) { + const auto check = [](const std::vector& points) { + for (const auto& w : points) { + EXPECT_NEAR(w.x + w.y + w.z, 1.0f, 1e-5f); + EXPECT_GT(std::min({ w.x, w.y, w.z }), 0.0f); + } + }; + // Half a stud-sized square: 7 x 7 points on the square + const auto half = UgcHsr::SamplePoints({ 0, 0, 0 }, { 0.8f, 0, 0 }, { 0.8f, 0, 0.8f }, 0.1143f); + check(half); + EXPECT_EQ(half.size(), 25u); + // Four times the area, about four times the points + const auto big = UgcHsr::SamplePoints({ 0, 0, 0 }, { 1.6f, 0, 0 }, { 1.6f, 0, 1.6f }, 0.1143f); + check(big); + EXPECT_EQ(big.size(), 100u); + // A tiny triangle: its centre and one towards each corner + const auto tiny = UgcHsr::SamplePoints({ 0, 0, 0 }, { 0.01f, 0, 0 }, { 0, 0.01f, 0 }, 0.1143f); + check(tiny); + EXPECT_EQ(tiny.size(), 4u); + // A long sliver: points along its length + const auto sliver = UgcHsr::SamplePoints({ 0, 0, 0 }, { 3.2f, 0, 0 }, { 1.6f, 0.01f, 0 }, 0.1143f); + check(sliver); + EXPECT_EQ(sliver.size(), 14u); + // With a minimum (LU Toolbox's 28 texels a triangle) small triangles get their points closer together + const auto dense = UgcHsr::SamplePoints({ 0, 0, 0 }, { 0.01f, 0, 0 }, { 0, 0.01f, 0 }, 0.1143f, 28); + check(dense); + EXPECT_GE(dense.size(), 28u); + EXPECT_LE(dense.size(), 40u); + EXPECT_EQ(UgcHsr::SamplePoints({ 0, 0, 0 }, { 1.6f, 0, 0 }, { 1.6f, 0, 1.6f }, 0.1143f, 28).size(), 100u); // bigger ones as before +}