From 00607a20a6f49fcf177a97f3c006f7ec487eba90 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 11:11:45 -0500 Subject: [PATCH] feat(ugc): the fast hidden-face method back, next to LU Toolbox's UgcHsr::Options::method picks how hidden faces are found: toolbox (LU Toolbox's paths, the default, unchanged) or fast, the test the UGC server used before "hidden faces removed as LU Toolbox's Remove Hidden Faces decides them", restored unchanged as UgcRender::VisibleFromAround: the opaque mesh rendered from 42 directions around the model (Options::fastResolution pixels square, 1024 as before) and the triangles that show in none removed. It is much faster, but it also removes faces seen only by bounced light (interiors, recesses), which LU Toolbox keeps. Nothing sets it yet. Check: the UGC tests (the fast method removes a box seen only through a chimney, which the paths keep; its files are the same every time). Co-Authored-By: Claude Opus 5.5 --- dUgcServer/Render/UgcHsr.cpp | 15 ++++- dUgcServer/Render/UgcHsr.h | 21 ++++++- dUgcServer/Render/UgcRender.cpp | 101 ++++++++++++++++++++++++++++++++ dUgcServer/Render/UgcRender.h | 11 ++++ tests/dUgcTests/UgcTests.cpp | 61 +++++++++++++++++++ 5 files changed, 205 insertions(+), 4 deletions(-) diff --git a/dUgcServer/Render/UgcHsr.cpp b/dUgcServer/Render/UgcHsr.cpp index 84c3d2620..88373c5ad 100644 --- a/dUgcServer/Render/UgcHsr.cpp +++ b/dUgcServer/Render/UgcHsr.cpp @@ -5,6 +5,7 @@ #include #include #include "UgcRays.h" +#include "UgcRender.h" #include "UgcThrottle.h" namespace { @@ -407,6 +408,17 @@ namespace { } namespace UgcHsr { + std::string_view Name(eMethod method) { + return method == eMethod::FAST ? "fast" : "toolbox"; + } + + std::optional Parse(std::string_view name) { + for (const auto method : { eMethod::TOOLBOX, eMethod::FAST }) { + if (Name(method) == name) return method; + } + return std::nullopt; + } + 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) @@ -501,7 +513,8 @@ namespace UgcHsr { 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); + result.kept = options.method == eMethod::FAST ? UgcRender::VisibleFromAround(model, options.fastResolution, options.groundPlane) : + 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/Render/UgcHsr.h b/dUgcServer/Render/UgcHsr.h index a29d9e448..646507f37 100644 --- a/dUgcServer/Render/UgcHsr.h +++ b/dUgcServer/Render/UgcHsr.h @@ -2,6 +2,8 @@ #include #include +#include +#include #include #include @@ -14,10 +16,21 @@ * 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. + * The fast method (hsr_method=fast) is the test the UGC server used before: renders from 42 directions around the + * model (UgcRender::VisibleFromAround), which removes faces seen only by bounced light too. */ namespace UgcHsr { + // How hidden faces are found (hsr_method): LU Toolbox's paths, or the renders from around the model + enum class eMethod : uint8_t { TOOLBOX = 0, FAST }; + + // The setting's name of a method (toolbox, fast) + std::string_view Name(eMethod method); + // A method by its name; nullopt for anything else + std::optional Parse(std::string_view name); + struct Options { bool enabled{ true }; // remove_hidden_faces + eMethod method{}; // hsr_method 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) @@ -25,14 +38,15 @@ namespace UgcHsr { 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) UgcRays::eBackend rays{}; // what traces the paths' rays (ugc_ray_backend) + int fastResolution{ 1024 }; // hsr_fast_resolution: pixels square of each of the fast method's renders }; 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 + uint64_t points{}; // sample points traced from (toolbox) + uint64_t paths{}; // paths traced (toolbox) }; /** @@ -41,7 +55,8 @@ namespace UgcHsr { */ 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 + // Removes the opaque triangles that aren't Visible, or with the fast method don't show in its renders (nothing when + // options.enabled is off); transparent ones stay Result RemoveHiddenFaces(UgcModel::Model& model, const Options& options); /** diff --git a/dUgcServer/Render/UgcRender.cpp b/dUgcServer/Render/UgcRender.cpp index 38a06901f..cb1c20792 100644 --- a/dUgcServer/Render/UgcRender.cpp +++ b/dUgcServer/Render/UgcRender.cpp @@ -80,9 +80,110 @@ 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; + } + + std::vector VisibleFromAround(const UgcModel::Model& model, int resolution, bool groundPlane) { + const auto& opaque = model.opaque; + const size_t triangles = opaque.TriangleCount(); + std::vector visible(triangles, false); + if (opaque.Empty()) return visible; + + glm::vec3 center{}; + float radius{}; + Bounds(model, center, radius); + radius *= 1.02f; + resolution = std::clamp(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()); + + 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 (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; + } + } + return visible; + } + std::vector AmbientOcclusion(const UgcModel::Mesh& mesh, const UgcModel::Mesh& occluders, float distance, int samples, UgcRays::eBackend rays) { 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/Render/UgcRender.h b/dUgcServer/Render/UgcRender.h index 6e6e66f73..fc730a5e3 100644 --- a/dUgcServer/Render/UgcRender.h +++ b/dUgcServer/Render/UgcRender.h @@ -71,6 +71,17 @@ 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); + /** + * The fast hidden-face test (hsr_method=fast, what the UGC server did before it traced LU Toolbox's paths): the + * opaque mesh rendered from 42 directions around the whole model (SphereDirections), `resolution` pixels square; + * per opaque triangle whether it shows in any of them (with a conservative test, so small visible ones stay). + * Faces seen only by bounced light (interiors, recesses) don't show. `groundPlane`: nothing is seen from below. + */ + std::vector VisibleFromAround(const UgcModel::Model& model, int resolution, bool groundPlane); + + // The 42 directions VisibleFromAround renders from (an icosahedron's corners and edge centres), unit length + std::vector SphereDirections(); + /** * 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`. diff --git a/tests/dUgcTests/UgcTests.cpp b/tests/dUgcTests/UgcTests.cpp index 44a5f168f..1d76406b6 100644 --- a/tests/dUgcTests/UgcTests.cpp +++ b/tests/dUgcTests/UgcTests.cpp @@ -2002,6 +2002,67 @@ namespace { } } +TEST(UgcHsr, FastMethodRendersFromAround) { + EXPECT_EQ(UgcHsr::Parse("fast"), UgcHsr::eMethod::FAST); + EXPECT_EQ(UgcHsr::Parse("toolbox"), UgcHsr::eMethod::TOOLBOX); + EXPECT_FALSE(UgcHsr::Parse("slow")); + EXPECT_EQ(UgcRender::SphereDirections().size(), 42u); + + // A small box inside the big one: its faces can't be seen + UgcBricks::BrickLibrary library(MakeRes(), 0); + std::string error; + const auto parts = UgcModel::ParseLxfml(R"( + + + )", error); + auto model = UgcModel::Build(parts, library); + UgcHsr::Options options; + options.method = UgcHsr::eMethod::FAST; + options.fastResolution = 256; + const auto result = UgcHsr::RemoveHiddenFaces(model, options); + EXPECT_EQ(result.trianglesRemoved, 12u); + EXPECT_EQ(model.opaque.TriangleCount(), 12u); + EXPECT_EQ(result.paths, 0u); + + // A small box in a closed chamber whose only opening is a narrow chimney at the other end: nothing outside sees it + // straight, so the fast method removes it; light bounced in through the chimney reaches some of it, so the toolbox + // method keeps that + UgcModel::Model chamber; + auto& mesh = chamber.opaque; + AddBox(mesh, glm::vec3(-0.9f, 0.0f, -0.15f), glm::vec3(-0.6f, 0.2f, 0.15f)); // the small box + AddBox(mesh, glm::vec3(-1.2f, -0.2f, -1.2f), glm::vec3(1.2f, 0.0f, 1.2f)); // floor + AddBox(mesh, glm::vec3(-1.2f, 0.0f, -1.2f), glm::vec3(-1.0f, 0.5f, 1.2f)); // walls + AddBox(mesh, glm::vec3(1.0f, 0.0f, -1.2f), glm::vec3(1.2f, 0.5f, 1.2f)); + AddBox(mesh, glm::vec3(-1.0f, 0.0f, -1.2f), glm::vec3(1.0f, 0.5f, -1.0f)); + AddBox(mesh, glm::vec3(-1.0f, 0.0f, 1.0f), glm::vec3(1.0f, 0.5f, 1.2f)); + AddBox(mesh, glm::vec3(-1.2f, 0.5f, -1.2f), glm::vec3(0.6f, 0.7f, 1.2f)); // roof around the chimney's hole + AddBox(mesh, glm::vec3(0.9f, 0.5f, -1.2f), glm::vec3(1.2f, 0.7f, 1.2f)); + AddBox(mesh, glm::vec3(0.6f, 0.5f, -1.2f), glm::vec3(0.9f, 0.7f, -0.15f)); + AddBox(mesh, glm::vec3(0.6f, 0.5f, 0.15f), glm::vec3(0.9f, 0.7f, 1.2f)); + AddBox(mesh, glm::vec3(0.5f, 0.7f, -0.25f), glm::vec3(0.6f, 2.2f, 0.25f)); // the chimney + AddBox(mesh, glm::vec3(0.9f, 0.7f, -0.25f), glm::vec3(1.0f, 2.2f, 0.25f)); + AddBox(mesh, glm::vec3(0.6f, 0.7f, -0.25f), glm::vec3(0.9f, 2.2f, -0.15f)); + AddBox(mesh, glm::vec3(0.6f, 0.7f, 0.15f), glm::vec3(0.9f, 2.2f, 0.25f)); + const auto seen = UgcRender::VisibleFromAround(chamber, 512, false); + ASSERT_EQ(seen.size(), mesh.TriangleCount()); + for (size_t t = 0; t < 12; t++) EXPECT_FALSE(seen[t]) << t; + UgcHsr::Options toolbox; + toolbox.samples = 32; + const auto traced = UgcHsr::Visible(mesh, toolbox); + EXPECT_TRUE(std::any_of(traced.begin(), traced.begin() + 12, [](bool kept) { return kept; })); + + // Its files are the same every time + auto settings = SmallSettings(); + settings.hsr.method = UgcHsr::eMethod::FAST; + settings.hsr.fastResolution = 128; + const auto first = UgcJobs::ProcessModel(LXFML5, library, settings, 7); + ASSERT_TRUE(first.ok) << first.error; + EXPECT_EQ(first.files.at("model.nif.checksum"), UgcJobs::ProcessModel(LXFML5, library, settings, 7).files.at("model.nif.checksum")); +#if defined(__linux__) && defined(__x86_64__) && defined(__GNUC__) && !defined(__clang__) + EXPECT_EQ(UgcFormats::Md5Hex(*ZCompression::Gunzip(first.files.at("model.nif.gz"))), "4bd664412c88333431842eb82a0abfac"); +#endif +} + TEST(UgcRays, NamesAndFallback) { for (const auto backend : { UgcRays::eBackend::BUILTIN, UgcRays::eBackend::EMBREE, UgcRays::eBackend::HIPRT }) { EXPECT_EQ(UgcRays::Parse(UgcRays::Name(backend)), backend);