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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 11:11:45 -05:00
parent 57110a7081
commit 00607a20a6
5 changed files with 205 additions and 4 deletions

View File

@@ -5,6 +5,7 @@
#include <cmath>
#include <limits>
#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<eMethod> Parse(std::string_view name) {
for (const auto method : { eMethod::TOOLBOX, eMethod::FAST }) {
if (Name(method) == name) return method;
}
return std::nullopt;
}
std::vector<glm::vec3> 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;

View File

@@ -2,6 +2,8 @@
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string_view>
#include <vector>
#include <glm/glm.hpp>
@@ -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<eMethod> 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<bool> 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<bool> 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);
/**

View File

@@ -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<glm::vec3> SphereDirections() {
const float t = (1.0f + std::sqrt(5.0f)) / 2.0f;
const std::vector<glm::vec3> 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<glm::vec3> 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<bool> VisibleFromAround(const UgcModel::Model& model, int resolution, bool groundPlane) {
const auto& opaque = model.opaque;
const size_t triangles = opaque.TriangleCount();
std::vector<bool> 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<size_t>(resolution) * resolution;
std::vector<float> depth(pixels);
std::vector<uint32_t> ids(pixels);
std::vector<glm::vec3> screen(opaque.positions.size());
std::vector<bool> facing(triangles, true);
std::vector<glm::vec3> 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<uint32_t>(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<size_t>(y) * resolution + x];
if (z < stored) {
stored = z;
ids[static_cast<size_t>(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<int>(centre.x), y = static_cast<int>(centre.y);
if (x < 0 || y < 0 || x >= resolution || y >= resolution || centre.z <= depth[static_cast<size_t>(y) * resolution + x] + bias) visible[t] = true;
}
}
return visible;
}
std::vector<float> AmbientOcclusion(const UgcModel::Mesh& mesh, const UgcModel::Mesh& occluders, float distance, int samples, UgcRays::eBackend rays) {
std::vector<float> ao(mesh.positions.size(), 1.0f);
if (occluders.Empty() || samples <= 0 || distance <= 0.0f || mesh.normals.size() != mesh.positions.size()) return ao;

View File

@@ -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<float>* 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<bool> 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<glm::vec3> 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`.

View File

@@ -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"(<LXFML versionMajor="5"><Bricks>
<Brick><Part designID="3002" materials="21"><Bone transformation="1,0,0,0,1,0,0,0,1,0,0,0"/></Part></Brick>
<Brick><Part designID="3001" materials="21"><Bone transformation="1,0,0,0,1,0,0,0,1,0,0,0"/></Part></Brick>
</Bricks></LXFML>)", 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);