From edba81ff7ca05b2da18a80898ea869f1ec61e8aa Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 12:15:50 -0500 Subject: [PATCH] refactor(ugc): Embree is required; the hand-written ray hierarchies are gone Embree 4 (always built) replaces the UGC server's own bounding volume hierarchies (the nearest-hit one and the occlusion rays' any-hit one), with no fallback to them. ray_backend is embree (default) or hiprt (optional build; Embree when it can't be used). Settings, stored options and stats that say builtin still read: it is embree (UgcRays::Parse, UgcProcessOptions::Parse). The dashboard's picker, /reprocessproperty and --make-model offer embree and hiprt. Tests: the backends are compared with Embree (hiprt when built), Embree against rays whose hits are known, and the clutter's occlusion against what the old hierarchy worked out (296 vertices summing to 114.5, 26 open, 183 dark); the pinned model hashes are unchanged with Embree. Check: ray_backend=builtin in an ini still starts and uses embree; the settings page offers Embree and HIPRT. Co-Authored-By: Claude Opus 5.5 --- dCommon/UgcKeys.h | 8 +- dDashboardServer/routes/SettingsCatalog.cpp | 2 +- dDashboardServer/routes/UgcRoutes.cpp | 2 +- dDashboardServer/static/js/ugc.js | 2 +- dDashboardServer/templates/ugc.jinja2 | 4 +- dGame/dUtilities/SlashCommandHandler.cpp | 2 +- .../SlashCommands/DEVGMCommands.cpp | 2 +- dUgcServer/Render/UgcRays.cpp | 391 +----------------- dUgcServer/Render/UgcRays.h | 11 +- dUgcServer/Render/UgcRender.h | 2 +- dUgcServer/UgcServer.cpp | 4 +- docs/Commands.md | 2 +- resources/ugcconfig.ini | 4 +- tests/dUgcTests/UgcTests.cpp | 101 +++-- 14 files changed, 107 insertions(+), 430 deletions(-) diff --git a/dCommon/UgcKeys.h b/dCommon/UgcKeys.h index 7cd7cf55e..b6ccaddb2 100644 --- a/dCommon/UgcKeys.h +++ b/dCommon/UgcKeys.h @@ -79,7 +79,7 @@ namespace UgcDebounce { * chosen names in this order, separated by spaces ("embree oidn"); a choice left out is the setting's. */ namespace UgcProcessOptions { - inline constexpr std::string_view RAYS[] = { "builtin", "embree", "hiprt" }; + inline constexpr std::string_view RAYS[] = { "embree", "hiprt" }; inline constexpr std::string_view DENOISE[] = { "off", "oidn" }; // Hidden-face methods earlier versions' options named (stored options still have them): read and ignored inline constexpr std::string_view RETIRED[] = { "toolbox", "fast" }; @@ -97,7 +97,7 @@ namespace UgcProcessOptions { } // The words, in any order ("oidn embree"); "default" or "-" leave a choice to the setting, retired words are - // skipped. False on an unknown word or two words for one choice. + // skipped and builtin is embree. False on an unknown word or two words for one choice. inline bool Parse(std::string_view text, Choice& choice) { choice = {}; size_t start = 0; @@ -105,9 +105,11 @@ namespace UgcProcessOptions { while (start < text.size() && (text[start] == ' ' || text[start] == ',')) start++; size_t end = start; while (end < text.size() && text[end] != ' ' && text[end] != ',') end++; - const auto word = text.substr(start, end - start); + auto word = text.substr(start, end - start); start = end; if (word.empty() || word == "default" || word == "-" || Contains(RETIRED, word)) continue; + // builtin, the ray backend Embree replaced, is embree + if (word == "builtin") word = "embree"; std::string* slot = Contains(RAYS, word) ? &choice.rays : Contains(DENOISE, word) ? &choice.denoise : nullptr; if (!slot || !slot->empty()) return false; *slot = std::string(word); diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 9b4c0c3f4..6fbbacf60 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -498,7 +498,7 @@ namespace { c.Add(Bool(UGC, "remove_hidden_faces", "Remove faces nobody can see", "The opaque bricks are rendered from 42 directions around the model; the triangles that show in none are removed. Faces seen only through openings or by bounced light go too.", 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, "hsr_resolution", "Detail of the visibility renders", "The size of each of the 42 renders; bigger keeps smaller visible faces.", "1024", 64, 4096), "pixels")); - c.Add(Labels(Choice(UGC, "ray_backend", "Ray tracer", "What traces the occlusion rays: builtin (the UGC server's own), embree (Intel Embree on the CPU) or hiprt (the GPU, when the server was built with it and has one; else embree). They give the same results but for rounding. Staff can pick another for one make when making models again.", "builtin", { "builtin", "embree", "hiprt" }), { "Built in", "Embree (CPU)", "HIPRT (GPU)" })); + c.Add(Labels(Choice(UGC, "ray_backend", "Ray tracer", "What traces the occlusion rays: embree (Intel Embree on the CPU) or hiprt (the GPU, when the server was built with it and has one; else embree). They give the same results but for rounding. Staff can pick another for one make when making models again.", "embree", { "embree", "hiprt" }), { "Embree (CPU)", "HIPRT (GPU)" })); c.Add(Int(UGC, "hiprt_device", "GPU for HIPRT", "Which GPU the hiprt ray tracer uses: 0 is the first HIP (AMD) or CUDA (NVIDIA) device.", "0", 0, 16, true)); c.Add(Labels(Choice(UGC, "denoise", "Denoise icons", "oidn (when the server was built with Intel Open Image Denoise; else off): a model's icon is drawn from its colors before the occlusion bake, with its occlusion traced per pixel with a few rays and the noise removed by the denoiser, instead of the baked occlusion. The model itself keeps its baked occlusion (a denoiser only works on images).", "off", { "off", "oidn" }), { "Off", "Open Image Denoise" })); c.Add(Int(UGC, "denoise_samples", "Denoised occlusion rays per pixel", "Occlusion rays traced from each pixel of a denoised icon (before it is scaled down, so 16 times as many per icon pixel); fewer is faster and noisier.", "4", 1, 256)); diff --git a/dDashboardServer/routes/UgcRoutes.cpp b/dDashboardServer/routes/UgcRoutes.cpp index faccf0316..d4042a5f9 100644 --- a/dDashboardServer/routes/UgcRoutes.cpp +++ b/dDashboardServer/routes/UgcRoutes.cpp @@ -478,7 +478,7 @@ namespace UgcRoutes { { "removed", summary.trianglesBefore > 0 ? 1.0 - static_cast(summary.triangles) / static_cast(summary.trianglesBefore) : 0.0 } }); } JsonSuccess(reply, { { "choices", { { "rays", names(UgcProcessOptions::RAYS) }, { "denoise", names(UgcProcessOptions::DENOISE) } } }, - { "defaults", { { "rays", UgcSetting("ray_backend").value_or("builtin") }, + { "defaults", { { "rays", UgcSetting("ray_backend").value_or("embree") }, { "denoise", UgcSetting("denoise").value_or("off") } } }, { "combinations", combos }, { "canManage", Can(context, "ugc_manage") } }); }); diff --git a/dDashboardServer/static/js/ugc.js b/dDashboardServer/static/js/ugc.js index 38195b39d..eeced9057 100644 --- a/dDashboardServer/static/js/ugc.js +++ b/dDashboardServer/static/js/ugc.js @@ -727,7 +727,7 @@ // ---- processing options: Make again's choices and the comparison of what made the models ---- - var OPTION_LABELS = { builtin: 'Built in', embree: 'Embree (CPU)', hiprt: 'HIPRT (GPU)', off: 'Off', oidn: 'Open Image Denoise' }; + var OPTION_LABELS = { embree: 'Embree (CPU)', hiprt: 'HIPRT (GPU)', off: 'Off', oidn: 'Open Image Denoise' }; function loadOptions() { return api.get('/api/ugc/options').then(function (d) { if (!d.success) throw new Error(d.error || 'Failed'); diff --git a/dDashboardServer/templates/ugc.jinja2 b/dDashboardServer/templates/ugc.jinja2 index af6b2c0fe..2f951e1c2 100644 --- a/dDashboardServer/templates/ugc.jinja2 +++ b/dDashboardServer/templates/ugc.jinja2 @@ -35,8 +35,8 @@
Processing options compared
-

The ways of making models to compare: the ray tracer of the occlusion (built in, Embree on the CPU, - HIPRT on the GPU) and denoising of icons. The settings pick the defaults; Make again can use others for a make (the options next +

The ways of making models to compare: the ray tracer of the occlusion (Embree on the CPU, HIPRT on the + GPU) and denoising of icons. The settings pick the defaults; Make again can use others for a make (the options next to it, or /reprocessproperty embree oidn in game). Every make is counted under what made it: the averages per make below, each model's in the List view's Options column.

diff --git a/dGame/dUtilities/SlashCommandHandler.cpp b/dGame/dUtilities/SlashCommandHandler.cpp index 079d910bb..7eb1aa765 100644 --- a/dGame/dUtilities/SlashCommandHandler.cpp +++ b/dGame/dUtilities/SlashCommandHandler.cpp @@ -497,7 +497,7 @@ void SlashCommandHandler::Startup() { Command ReprocessPropertyCommand{ .help = "Make this property's models again and reload it", - .info = "The UGC server makes every model placed on the property you are on again, with the current UGC settings or the processing options given (in any order: builtin, embree or hiprt; off or oidn). Once they are made, everyone on the property is sent back into it, so their game loads the new meshes", + .info = "The UGC server makes every model placed on the property you are on again, with the current UGC settings or the processing options given (in any order: embree or hiprt; off or oidn). Once they are made, everyone on the property is sent back into it, so their game loads the new meshes", .aliases = { "reprocessproperty", "reloadpropertymodels" }, .handle = DEVGMCommands::ReprocessProperty, .requiredLevel = eGameMasterLevel::DEVELOPER diff --git a/dGame/dUtilities/SlashCommands/DEVGMCommands.cpp b/dGame/dUtilities/SlashCommands/DEVGMCommands.cpp index e715e05f2..acc66df1c 100644 --- a/dGame/dUtilities/SlashCommands/DEVGMCommands.cpp +++ b/dGame/dUtilities/SlashCommands/DEVGMCommands.cpp @@ -1993,7 +1993,7 @@ namespace DEVGMCommands { // Optional processing options for this make: ray backend and denoising, in any order UgcProcessOptions::Choice choice; if (!UgcProcessOptions::Parse(args, choice)) { - ChatPackets::SendSystemMessage(sysAddr, u"Usage: /reprocessproperty [builtin|embree|hiprt] [off|oidn] (left out: the UGC settings')"); + ChatPackets::SendSystemMessage(sysAddr, u"Usage: /reprocessproperty [embree|hiprt] [off|oidn] (left out: the UGC settings')"); return; } const auto options = UgcProcessOptions::ToString(choice); diff --git a/dUgcServer/Render/UgcRays.cpp b/dUgcServer/Render/UgcRays.cpp index 93b3ada25..39c544efc 100644 --- a/dUgcServer/Render/UgcRays.cpp +++ b/dUgcServer/Render/UgcRays.cpp @@ -1,14 +1,11 @@ #include "UgcRays.h" #include -#include #include #include #include -#include #include #include -#include #include @@ -20,375 +17,6 @@ namespace { using UgcRays::Hit; using UgcRays::INF; - /** - * A bounding volume hierarchy (binned surface area heuristic) over a mesh's triangles, for the nearest hit. A ray never hits the triangle it leaves (`skip`), as in Cycles. - */ - class ClosestBvh { - public: - explicit ClosestBvh(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; - }; - - // A bounding volume hierarchy over a mesh's triangles (median splits), for the occlusion rays: any hit. The mesh - // is only read while it is built. - class AnyBvh { - public: - explicit AnyBvh(const UgcModel::Mesh& mesh) : m_Mesh(mesh) { - const size_t count = mesh.TriangleCount(); - m_Order.resize(count); - std::iota(m_Order.begin(), m_Order.end(), 0u); - m_Centers.resize(count); - for (size_t t = 0; t < count; t++) m_Centers[t] = (Vertex(t, 0) + Vertex(t, 1) + Vertex(t, 2)) / 3.0f; - if (count > 0) Build(0, static_cast(count)); - Flatten(); - } - - // Whether a ray from `origin` along `direction` (unit) hits a triangle further than `minDistance` and nearer - // than `maxDistance` - bool Hits(const glm::vec3& origin, const glm::vec3& direction, float minDistance, float maxDistance) const { - if (m_Nodes.empty()) return false; - const glm::vec3 inverse(1.0f / (std::abs(direction.x) > 1e-12f ? direction.x : 1e-12f), 1.0f / (std::abs(direction.y) > 1e-12f ? direction.y : 1e-12f), - 1.0f / (std::abs(direction.z) > 1e-12f ? direction.z : 1e-12f)); - uint32_t stack[64]; - int top = 0; - stack[top++] = 0; - while (top > 0) { - const auto& node = m_Nodes[stack[--top]]; - if (!BoxHit(node, origin, inverse, maxDistance)) continue; - if (node.count > 0) { - for (uint32_t i = node.first; i < node.first + node.count; i++) { - if (TriangleHit(m_Triangles[i], origin, direction, minDistance, maxDistance)) return true; - } - } else if (top < 62) { - stack[top++] = node.first; - stack[top++] = node.first + 1; - } - } - return false; - } - - private: - struct Node { - glm::vec3 min{}; - glm::vec3 max{}; - uint32_t first{}; // leaf: first triangle in m_Order; inner: the first of two children - uint32_t count{}; // triangles, 0 for inner nodes - }; - - glm::vec3 Vertex(size_t t, int k) const { return m_Mesh.positions[m_Mesh.indices[t * 3 + k]]; } - - void Build(uint32_t first, uint32_t count) { - // Iterative, so deep trees don't use the stack - struct Task { uint32_t node, first, count; }; - m_Nodes.push_back({}); - std::vector tasks{ { 0, first, count } }; - while (!tasks.empty()) { - const auto task = tasks.back(); - tasks.pop_back(); - Node node; - node.min = glm::vec3(INF); - node.max = glm::vec3(-INF); - glm::vec3 centerMin(INF), centerMax(-INF); - for (uint32_t i = task.first; i < task.first + task.count; i++) { - for (int k = 0; k < 3; k++) { - node.min = glm::min(node.min, Vertex(m_Order[i], k)); - node.max = glm::max(node.max, Vertex(m_Order[i], k)); - } - centerMin = glm::min(centerMin, m_Centers[m_Order[i]]); - centerMax = glm::max(centerMax, m_Centers[m_Order[i]]); - } - const auto extent = centerMax - centerMin; - const int axis = extent.x >= extent.y && extent.x >= extent.z ? 0 : extent.y >= extent.z ? 1 : 2; - if (task.count <= 4 || extent[axis] <= 0.0f) { - node.first = task.first; - node.count = task.count; - m_Nodes[task.node] = node; - continue; - } - const uint32_t half = task.count / 2; - auto* begin = m_Order.data() + task.first; - std::nth_element(begin, begin + half, begin + task.count, [&](uint32_t a, uint32_t b) { return m_Centers[a][axis] < m_Centers[b][axis]; }); - 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, half }); - tasks.push_back({ node.first + 1, task.first + half, task.count - half }); - } - } - - static bool BoxHit(const Node& node, const glm::vec3& origin, const glm::vec3& inverse, float maxDistance) { - 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, maxDistance)); - return enter <= exit; - } - - struct Triangle { - glm::vec3 a, e1, e2; - }; - - // The triangles in leaf order, edges worked out once (the rays read them far more often than the tree is built) - void Flatten() { - m_Triangles.reserve(m_Order.size()); - for (const auto t : m_Order) { - const auto a = Vertex(t, 0); - m_Triangles.push_back({ a, Vertex(t, 1) - a, Vertex(t, 2) - a }); - } - } - - static bool TriangleHit(const Triangle& triangle, const glm::vec3& origin, const glm::vec3& direction, float minDistance, float maxDistance) { - const auto& a = triangle.a; - const auto& e1 = triangle.e1; - const auto& e2 = triangle.e2; - const auto p = glm::cross(direction, e2); - const float det = glm::dot(e1, p); - if (std::abs(det) < 1e-12f) return false; - const float inv = 1.0f / det; - const auto s = origin - a; - const float u = glm::dot(s, p) * inv; - if (u < 0.0f || u > 1.0f) return false; - const auto q = glm::cross(s, e1); - const float v = glm::dot(direction, q) * inv; - if (v < 0.0f || u + v > 1.0f) return false; - const float distance = glm::dot(e2, q) * inv; - return distance > minDistance && distance < maxDistance; - } - - const UgcModel::Mesh& m_Mesh; - std::vector m_Triangles; - std::vector m_Order; - std::vector m_Centers; - std::vector m_Nodes; - }; - - /** - * builtin: the hierarchy each query had before the backends (the paths' nearest hits: ClosestBvh; the occlusion - * rays: AnyBvh), each built the first time it is asked, so the results are exactly what they were. - */ - class BuiltinScene final : public UgcRays::Scene { - public: - explicit BuiltinScene(const UgcModel::Mesh& mesh) { - m_Mesh.positions = mesh.positions; - m_Mesh.indices = mesh.indices; - } - - Hit Closest(const glm::vec3& origin, const glm::vec3& direction, uint32_t skip, float maxT) const override { - if (!m_Closest) m_Closest = std::make_unique(m_Mesh); - return m_Closest->Closest(origin, direction, skip, maxT); - } - - bool Occluded(const glm::vec3& origin, const glm::vec3& direction, float minT, float maxT) const override { - if (!m_Any) m_Any = std::make_unique(m_Mesh); - return m_Any->Hits(origin, direction, minT, maxT); - } - - private: - UgcModel::Mesh m_Mesh; // positions and indices only - mutable std::unique_ptr m_Closest; - mutable std::unique_ptr m_Any; - }; - // Embree's device for the thread: one per thread, with no threads of its own (threads=1: the thread that commits // a scene builds it), released when the thread ends struct EmbreeDevice { @@ -493,7 +121,7 @@ namespace { hit.t = maxT; if (m_Empty) return hit; RTCRayHit query{}; - // Further than 0, as the builtin test + // Further than 0 (the ray leaves a surface) query.ray = EmbreeRay(origin, direction, std::numeric_limits::min(), maxT); query.hit.geomID = RTC_INVALID_GEOMETRY_ID; query.hit.primID = RTC_INVALID_GEOMETRY_ID; @@ -518,7 +146,7 @@ namespace { bool Occluded(const glm::vec3& origin, const glm::vec3& direction, float minT, float maxT) const override { if (m_Empty) return false; - // Embree counts hits at exactly tnear and tfar; the builtin test doesn't + // Hits exactly at minT or maxT don't count (Embree counts them) auto ray = EmbreeRay(origin, direction, std::nextafter(minT, INF), std::nextafter(maxT, 0.0f)); rtcOccluded1(m_Scene, &ray, nullptr); return ray.tfar < 0.0f; @@ -540,15 +168,13 @@ namespace UgcRays { } std::string_view Name(eBackend backend) { - switch (backend) { - case eBackend::EMBREE: return "embree"; - case eBackend::HIPRT: return "hiprt"; - default: return "builtin"; - } + return backend == eBackend::HIPRT ? "hiprt" : "embree"; } std::optional Parse(std::string_view name) { - for (const auto backend : { eBackend::BUILTIN, eBackend::EMBREE, eBackend::HIPRT }) { + // builtin: the UGC server's own hierarchies, which Embree replaced (settings and options that name it) + if (name == "builtin") return eBackend::EMBREE; + for (const auto backend : { eBackend::EMBREE, eBackend::HIPRT }) { if (Name(backend) == name) return backend; } return std::nullopt; @@ -558,7 +184,7 @@ namespace UgcRays { #ifdef DLU_HIPRT if (backend == eBackend::HIPRT) return UgcRaysHiprt::Available(); #endif - return backend == eBackend::BUILTIN || backend == eBackend::EMBREE; + return backend == eBackend::EMBREE; } eBackend Resolve(eBackend wanted) { @@ -581,8 +207,7 @@ namespace UgcRays { if (auto scene = UgcRaysHiprt::Make(mesh)) return scene; return std::make_unique(mesh); #endif - case eBackend::EMBREE: return std::make_unique(mesh); - default: return std::make_unique(mesh); + default: return std::make_unique(mesh); } } diff --git a/dUgcServer/Render/UgcRays.h b/dUgcServer/Render/UgcRays.h index ba0711297..1030b23db 100644 --- a/dUgcServer/Render/UgcRays.h +++ b/dUgcServer/Render/UgcRays.h @@ -14,8 +14,7 @@ /** * Rays against a mesh's triangles: the nearest hit (never the triangle a ray leaves) and, for the ambient occlusion * rays, whether anything is hit, by one of several backends (the ray_backend setting, or per job): - * builtin: the UGC server's own bounding volume hierarchies (the ones it always had) - * embree: Intel's Embree 4 on the CPU, on the thread that asks (no threads of its own) + * embree: Intel's Embree 4 on the CPU, on the thread that asks (no threads of its own); always there * hiprt: AMD's HIPRT on the GPU (AMD through HIP, NVIDIA through CUDA, loaded when first asked for by Orochi), * when built with DLU_HIPRT and a GPU is there; else embree. One GPU for the process, used by one thread * at a time; its time is not CPU time. @@ -26,13 +25,13 @@ namespace UgcRays { constexpr uint32_t NONE = std::numeric_limits::max(); constexpr float INF = std::numeric_limits::infinity(); - enum class eBackend : uint8_t { BUILTIN = 0, EMBREE, HIPRT }; + enum class eBackend : uint8_t { EMBREE = 0, HIPRT }; - // The setting's name of a backend (builtin, embree, hiprt) + // The setting's name of a backend (embree, hiprt) std::string_view Name(eBackend backend); - // A backend by its name (case sensitive); nullopt for anything else + // A backend by its name (case sensitive; builtin, the backend Embree replaced, is embree); nullopt for anything else std::optional Parse(std::string_view name); - // Whether this build and machine can use the backend (builtin and embree always) + // Whether this build and machine can use the backend (embree always) bool Available(eBackend backend); // The backend that is used when `wanted` is asked for: itself, or embree when it isn't available eBackend Resolve(eBackend wanted); diff --git a/dUgcServer/Render/UgcRender.h b/dUgcServer/Render/UgcRender.h index fdb6ee8fe..e70354ab4 100644 --- a/dUgcServer/Render/UgcRender.h +++ b/dUgcServer/Render/UgcRender.h @@ -108,7 +108,7 @@ namespace UgcRender { * 1 is open, 0 fully hidden. */ std::vector AmbientOcclusion(const UgcModel::Mesh& mesh, const UgcModel::Mesh& occluders, float distance, int samples, - UgcRays::eBackend rays = UgcRays::eBackend::BUILTIN); + UgcRays::eBackend rays = UgcRays::eBackend::EMBREE); /** * LU Toolbox's Bake Lighting with AO Only (its defaults): the opaque mesh's occlusion (transparent bricks are hidden diff --git a/dUgcServer/UgcServer.cpp b/dUgcServer/UgcServer.cpp index 35913b34e..02a066682 100644 --- a/dUgcServer/UgcServer.cpp +++ b/dUgcServer/UgcServer.cpp @@ -157,7 +157,7 @@ namespace { settings.hsr.groundPlane = Setting("hsr_ground_plane", 0) != 0; settings.hsr.resolution = std::clamp(Setting("hsr_resolution", 1024), 64, 4096); // What traces the occlusion rays (the icon's too) - settings.ao.rays = UgcRays::Parse(Game::config->GetValue("ray_backend")).value_or(UgcRays::eBackend::BUILTIN); + settings.ao.rays = UgcRays::Parse(Game::config->GetValue("ray_backend")).value_or(UgcRays::eBackend::EMBREE); // The GPU hiprt uses (read before it is first used; changing it takes a restart) UgcRays::SetGpuDevice(std::max(Setting("hiprt_device", 0), 0)); settings.ao.enabled = Setting("bake_ao", 1) != 0; @@ -573,7 +573,7 @@ namespace { auto settings = ReadSettings(); UgcProcessOptions::Choice choice; if (!UgcProcessOptions::Parse(options, choice)) { - std::cerr << "Unknown processing options \"" << options << "\" (ray backend builtin, embree or hiprt; denoise off or oidn)\n"; + std::cerr << "Unknown processing options \"" << options << "\" (ray backend embree or hiprt; denoise off or oidn)\n"; return EXIT_FAILURE; } UgcJobs::ApplyOptions(settings, choice); diff --git a/docs/Commands.md b/docs/Commands.md index f28d47cd6..978c99408 100644 --- a/docs/Commands.md +++ b/docs/Commands.md @@ -137,7 +137,7 @@ These commands are primarily for development and testing. The usage of many of t |resurrect|`/resurrect`|Resurrects the player.|8| |setminifig|`/setminifig `|Alters your player's minifig. Body part can be one of "Eyebrows", "Eyes", "HairColor", "HairStyle", "Pants", "LeftHand", "Mouth", "RightHand", "Shirt", or "Hands". Changing minifig parts could break the character so this command is limited to GMs.|1| |testmap|`/testmap (clone-id) (instance-id) (spawn-point)`|Transfers you to the given zone by id and clone id and then spawns you at the specified spawn point if one was specified. Ignores instance-id for now. Aliases: `/tm`.|1| -|reprocessproperty|`/reprocessproperty [builtin\|embree\|hiprt] [off\|oidn]`|Has the UGC server make every brick built model on the property you are on again, with the current UGC settings or the processing options given, in any order (ray backend, denoising; see docs/UgcServer.md, "Processing options"). When they are all made (at most 15 minutes), everyone on the property is sent back into it, so their game downloads the new meshes. Aliases: `/reloadpropertymodels`.|8| +|reprocessproperty|`/reprocessproperty [embree\|hiprt] [off\|oidn]`|Has the UGC server make every brick built model on the property you are on again, with the current UGC settings or the processing options given, in any order (ray backend, denoising; see docs/UgcServer.md, "Processing options"). When they are all made (at most 15 minutes), everyone on the property is sent back into it, so their game downloads the new meshes. Aliases: `/reloadpropertymodels`.|8| |reportproxphys|`/reportproxphys`|Prints to console the position and radius of proximity sensors.|9| |spawnphysicsverts|`/spawnphysicsverts`|Spawns a 1x1 brick at all vertices of phantom physics objects|8| |teleport|`/teleport (y) `|Teleports you. If no Y is given, you are teleported to the height of the terrain or physics object at (x, z). Any of the coordinates can use the syntax of an exact position (10.0), or a relative position (~+10.0). A ~ means use the current value of that axis as the base value. Addition or subtraction is supported (~+10) (~-10). If source player and target player are players that exist in the world, then the source player will be teleported to target player. Aliases: `/tele`, `/tp`.|6| diff --git a/resources/ugcconfig.ini b/resources/ugcconfig.ini index f11eef59e..30f992790 100644 --- a/resources/ugcconfig.ini +++ b/resources/ugcconfig.ini @@ -136,10 +136,10 @@ remove_hidden_faces=1 hsr_ground_plane=0 hsr_resolution=1024 -# What traces the occlusion rays: builtin, embree (Intel Embree on the CPU) or +# What traces the occlusion rays: embree (Intel Embree on the CPU) or # hiprt (the GPU, when built with DLU_HIPRT and the machine has one; else embree). hiprt_device: which GPU (0: the first # HIP or CUDA device; restart to change). -ray_backend=builtin +ray_backend=embree hiprt_device=0 # denoise: off, or oidn (when built with DLU_OIDN; else off): a model's icon is drawn from its colors before the diff --git a/tests/dUgcTests/UgcTests.cpp b/tests/dUgcTests/UgcTests.cpp index d5948917f..8981db2bf 100644 --- a/tests/dUgcTests/UgcTests.cpp +++ b/tests/dUgcTests/UgcTests.cpp @@ -1932,12 +1932,10 @@ namespace { return mesh; } - // The backends other than builtin that this build and machine can use + // The backends other than embree that this build and machine can use std::vector OtherBackends() { std::vector backends; - for (const auto backend : { UgcRays::eBackend::EMBREE, UgcRays::eBackend::HIPRT }) { - if (UgcRays::Available(backend)) backends.push_back(backend); - } + if (UgcRays::Available(UgcRays::eBackend::HIPRT)) backends.push_back(UgcRays::eBackend::HIPRT); return backends; } } @@ -2015,7 +2013,10 @@ TEST(UgcProcessOptions, ParseApplyAndRecord) { // Applied over the settings; what made a model is recorded as it was used UgcJobs::Settings settings; - EXPECT_EQ(UgcProcessOptions::ToString(UgcJobs::MadeWith(settings)), "builtin off"); + EXPECT_EQ(UgcProcessOptions::ToString(UgcJobs::MadeWith(settings)), "embree off"); + // builtin, the ray backend Embree replaced, is embree + ASSERT_TRUE(UgcProcessOptions::Parse("builtin toolbox off", choice)); + EXPECT_EQ(UgcProcessOptions::ToString(choice), "embree off"); ASSERT_TRUE(UgcProcessOptions::Parse("embree", choice)); UgcJobs::ApplyOptions(settings, choice); EXPECT_EQ(settings.ao.rays, UgcRays::eBackend::EMBREE); @@ -2086,29 +2087,66 @@ TEST(UgcRender, DenoisedIconsTraceTheOcclusionPerPixel) { } TEST(UgcRays, NamesAndFallback) { - for (const auto backend : { UgcRays::eBackend::BUILTIN, UgcRays::eBackend::EMBREE, UgcRays::eBackend::HIPRT }) { + for (const auto backend : { UgcRays::eBackend::EMBREE, UgcRays::eBackend::HIPRT }) { EXPECT_EQ(UgcRays::Parse(UgcRays::Name(backend)), backend); } + EXPECT_EQ(UgcRays::Parse("builtin"), UgcRays::eBackend::EMBREE); // the backend Embree replaced EXPECT_FALSE(UgcRays::Parse("optix")); - EXPECT_TRUE(UgcRays::Available(UgcRays::eBackend::BUILTIN)); EXPECT_TRUE(UgcRays::Available(UgcRays::eBackend::EMBREE)); // A backend this machine can't use falls back to embree EXPECT_EQ(UgcRays::Resolve(UgcRays::eBackend::HIPRT), UgcRays::Available(UgcRays::eBackend::HIPRT) ? UgcRays::eBackend::HIPRT : UgcRays::eBackend::EMBREE); - EXPECT_EQ(UgcRays::Resolve(UgcRays::eBackend::BUILTIN), UgcRays::eBackend::BUILTIN); + EXPECT_EQ(UgcRays::Resolve(UgcRays::eBackend::EMBREE), UgcRays::eBackend::EMBREE); // An empty mesh is hit by nothing - for (const auto backend : { UgcRays::eBackend::BUILTIN, UgcRays::eBackend::EMBREE }) { + for (const auto backend : { UgcRays::eBackend::EMBREE, UgcRays::eBackend::HIPRT }) { const auto scene = UgcRays::Make(backend, UgcModel::Mesh{}); EXPECT_EQ(scene->Closest(glm::vec3(0.0f), glm::vec3(0, 1, 0)).triangle, UgcRays::NONE); EXPECT_FALSE(scene->Occluded(glm::vec3(0.0f), glm::vec3(0, 1, 0), 0.0f, 10.0f)); } } -TEST(UgcRays, BackendsFindTheSameHits) { - // Rays in every direction from points around the clutter: the other backends find the same nearest triangle at - // the same distance, and agree on what blocks. A ray through an edge two triangles share may hit either, at the - // same distance. +TEST(UgcRays, FindsTheExpectedHits) { + // The room of the clutter is [-2, 2]^3 (its walls face inwards; triangles 12 on), the box in it [-0.3, 0.3]^3 + // (triangles 0 to 11): rays whose hits are known const auto mesh = Clutter(); - const auto builtin = UgcRays::Make(UgcRays::eBackend::BUILTIN, mesh); + for (const auto backend : { UgcRays::eBackend::EMBREE, UgcRays::eBackend::HIPRT }) { + const auto scene = UgcRays::Make(backend, mesh); + const auto name = std::string(UgcRays::Name(UgcRays::Resolve(backend))); + // From the box's +X face out along +X: the room's +X wall (triangles 16 and 17) 1.7 away + auto hit = scene->Closest({ 0.3f, 0.1f, 0.1f }, { 1, 0, 0 }); + EXPECT_NEAR(hit.t, 1.7f, 1e-5f) << name; + EXPECT_TRUE(hit.triangle == 16 || hit.triangle == 17) << name << " " << hit.triangle; + // The same ray from the wall back: the box's +X face (triangles 2 and 3), not the wall it leaves + hit = scene->Closest({ 2.0f, 0.1f, 0.1f }, { -1, 0, 0 }, hit.triangle); + EXPECT_NEAR(hit.t, 1.7f, 1e-5f) << name; + EXPECT_TRUE(hit.triangle == 2 || hit.triangle == 3) << name << " " << hit.triangle; + // Nothing nearer than maxT: no hit, t = maxT + hit = scene->Closest({ 0.3f, 0.1f, 0.1f }, { 1, 0, 0 }, UgcRays::NONE, 1.5f); + EXPECT_EQ(hit.triangle, UgcRays::NONE) << name; + EXPECT_EQ(hit.t, 1.5f) << name; + // Out through the doorway (x -0.5..0.5, y -2..0 in the +Z wall): nothing + EXPECT_EQ(scene->Closest({ 0.0f, -1.0f, 1.0f }, { 0, 0, 1 }).triangle, UgcRays::NONE) << name; + // Occluded counts hits between minT and maxT only + EXPECT_TRUE(scene->Occluded({ 0.3f, 0.1f, 0.1f }, { 1, 0, 0 }, 1e-4f, 1.8f)) << name; + EXPECT_FALSE(scene->Occluded({ 0.3f, 0.1f, 0.1f }, { 1, 0, 0 }, 1e-4f, 1.6f)) << name; + EXPECT_FALSE(scene->Occluded({ 0.3f, 0.1f, 0.1f }, { 1, 0, 0 }, 1.75f, 3.0f)) << name; + // Batches answer as single rays do + std::vector rays{ { { 0.3f, 0.1f, 0.1f }, 1e-4f, { 1, 0, 0 }, 1.8f }, { { 0.3f, 0.1f, 0.1f }, 1e-4f, { 1, 0, 0 }, 1.6f } }; + std::vector occluded(2); + scene->Occluded(rays.data(), occluded.data(), rays.size()); + EXPECT_EQ(occluded, (std::vector{ 1, 0 })) << name; + std::vector hits(2); + scene->Closest(rays.data(), hits.data(), rays.size()); + EXPECT_NEAR(hits[0].t, 1.7f, 1e-5f) << name; + EXPECT_EQ(hits[1].triangle, UgcRays::NONE) << name; + } +} + +TEST(UgcRays, BackendsFindTheSameHits) { + // Rays in every direction from points around the clutter: the other backends find the nearest triangle Embree + // finds at the same distance, and agree on what blocks. A ray through an edge two triangles share may hit either, + // at the same distance. + const auto mesh = Clutter(); + const auto embree = UgcRays::Make(UgcRays::eBackend::EMBREE, mesh); for (const auto backend : OtherBackends()) { const auto other = UgcRays::Make(backend, mesh); uint64_t state = 12345; @@ -2121,7 +2159,7 @@ TEST(UgcRays, BackendsFindTheSameHits) { const glm::vec3 origin(next() * 3.8f - 1.9f, next() * 3.8f - 1.9f, next() * 3.8f - 1.9f); const auto direction = glm::normalize(glm::vec3(next() - 0.5f, next() - 0.5f, next() - 0.5f) + glm::vec3(1e-4f)); const auto skip = static_cast(next() * static_cast(mesh.TriangleCount())); - const auto a = builtin->Closest(origin, direction, skip); + const auto a = embree->Closest(origin, direction, skip); const auto b = other->Closest(origin, direction, skip); ASSERT_EQ(a.triangle == UgcRays::NONE, b.triangle == UgcRays::NONE) << UgcRays::Name(backend) << " ray " << i; if (a.triangle == UgcRays::NONE) continue; @@ -2134,7 +2172,7 @@ TEST(UgcRays, BackendsFindTheSameHits) { EXPECT_NEAR(a.v, b.v, 1e-3f); } const float maxT = next() * 4.0f; - EXPECT_EQ(builtin->Occluded(origin, direction, 1e-4f, maxT), other->Occluded(origin, direction, 1e-4f, maxT)) << UgcRays::Name(backend) << " ray " << i; + EXPECT_EQ(embree->Occluded(origin, direction, 1e-4f, maxT), other->Occluded(origin, direction, 1e-4f, maxT)) << UgcRays::Name(backend) << " ray " << i; // Limited: the nearest hit before maxT, or none const auto limited = other->Closest(origin, direction, skip, maxT); EXPECT_EQ(limited.triangle != UgcRays::NONE, b.t < maxT) << UgcRays::Name(backend) << " ray " << i; @@ -2145,19 +2183,32 @@ TEST(UgcRays, BackendsFindTheSameHits) { } TEST(UgcRays, OtherBackendsMakeTheSameOcclusion) { - // The occlusion with each backend, within rounding of builtin's; the small test model's files are the same + // The clutter's occlusion is what the UGC server's own hierarchy (which Embree replaced) worked out: 296 vertices, + // their occlusion summing to 114.5, 26 of them fully open and 183 darker than a half. Embree's and the other + // backends' are within rounding of that; the small test model's files are the same with every backend. const auto mesh = Clutter(); - const auto aoExpected = UgcRender::AmbientOcclusion(mesh, mesh, 2.0f, 64); + const auto expected = UgcRender::AmbientOcclusion(mesh, mesh, 2.0f, 64); + ASSERT_EQ(expected.size(), 296u); + double sum = 0.0; + size_t open = 0, dark = 0; + for (const float value : expected) { + sum += value; + open += value == 1.0f ? 1 : 0; + dark += value < 0.5f ? 1 : 0; + } + EXPECT_NEAR(sum, 114.5, 0.5); + EXPECT_NEAR(static_cast(open), 26.0, 2.0); + EXPECT_NEAR(static_cast(dark), 183.0, 3.0); UgcBricks::BrickLibrary library(MakeRes(), 0); - const auto builtinModel = UgcJobs::ProcessModel(LOOKS_LXFML, library, SmallSettings(), 7); - ASSERT_TRUE(builtinModel.ok) << builtinModel.error; + const auto embreeModel = UgcJobs::ProcessModel(LOOKS_LXFML, library, SmallSettings(), 7); + ASSERT_TRUE(embreeModel.ok) << embreeModel.error; for (const auto backend : OtherBackends()) { const auto ao = UgcRender::AmbientOcclusion(mesh, mesh, 2.0f, 64, backend); - ASSERT_EQ(ao.size(), aoExpected.size()); + ASSERT_EQ(ao.size(), expected.size()); double total = 0.0; for (size_t v = 0; v < ao.size(); v++) { - EXPECT_NEAR(ao[v], aoExpected[v], 0.05f) << UgcRays::Name(backend) << " vertex " << v; - total += std::abs(ao[v] - aoExpected[v]); + EXPECT_NEAR(ao[v], expected[v], 0.05f) << UgcRays::Name(backend) << " vertex " << v; + total += std::abs(ao[v] - expected[v]); } EXPECT_LT(total / static_cast(ao.size()), 0.002) << UgcRays::Name(backend); @@ -2165,7 +2216,7 @@ TEST(UgcRays, OtherBackendsMakeTheSameOcclusion) { settings.ao.rays = backend; const auto made = UgcJobs::ProcessModel(LOOKS_LXFML, library, settings, 7); ASSERT_TRUE(made.ok) << made.error; - EXPECT_EQ(made.files.at("model.nif.checksum"), builtinModel.files.at("model.nif.checksum")) << UgcRays::Name(backend); - EXPECT_EQ(made.files.at("icon.png"), builtinModel.files.at("icon.png")) << UgcRays::Name(backend); + EXPECT_EQ(made.files.at("model.nif.checksum"), embreeModel.files.at("model.nif.checksum")) << UgcRays::Name(backend); + EXPECT_EQ(made.files.at("icon.png"), embreeModel.files.at("icon.png")) << UgcRays::Name(backend); } }