From 76cbbcc4ea6da9dbe37b9deb7c35ffd2f7923eca Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 09:59:29 -0500 Subject: [PATCH] feat(ugc): draw player models' icons from their generated NIF The icon was rendered from a second mesh built only for it (the icon renderer's color corrections, no variation, occlusion worked out again). It is now drawn from the .nif just made, read back with NifFile at LOD 0, so it shows exactly what the game shows: the color variation, the removed faces and the lighting baked into the vertex colors (so it adds no occlusion of its own). icon_correct_colors and icon_color_variation are gone; the camera and light settings stay. Cars and rockets are drawn as before. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/routes/SettingsCatalog.cpp | 4 +-- dUgcServer/UgcJobs.cpp | 38 +++++++++------------ dUgcServer/UgcJobs.h | 4 +-- dUgcServer/UgcServer.cpp | 2 -- docs/UgcServer.md | 17 ++++----- resources/ugcconfig.ini | 7 ++-- tests/dUgcTests/UgcTests.cpp | 4 +++ 7 files changed, 34 insertions(+), 42 deletions(-) diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 116ef9973..f30e985b3 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -456,9 +456,7 @@ namespace { c.Add(Float(UGC, "icon_sun_strength", "Icon sun strength", "", "2.5", 0, 100)); c.Add(Float(UGC, "icon_ambient", "Icon world light", "", "0.192", 0, 10)); c.Add(Bool(UGC, "icon_shadows", "Icon shadows", "", true)); - c.Add(Bool(UGC, "icon_ao", "Icon ambient occlusion", "", true)); - c.Add(Bool(UGC, "icon_correct_colors", "Icon color corrections", "White and black toned down, as LU Toolbox's icon renderer does.", true)); - c.Add(Unit(Float(UGC, "icon_color_variation", "Icon color variation", "", "0", 0, 100), "%")); + c.Add(Bool(UGC, "icon_ao", "Car and rocket icon ambient occlusion", "Player models' icons show the lighting baked into their mesh instead.", true)); c.Add(Unit(Float(UGC, "modular_icon_yaw", "Car and rocket icon angle around", "", "53.36", -360, 360), "degrees")); c.Add(Unit(Float(UGC, "modular_icon_pitch", "Car and rocket icon angle above", "", "19.54", -90, 90), "degrees")); diff --git a/dUgcServer/UgcJobs.cpp b/dUgcServer/UgcJobs.cpp index 902bbeee5..b53941d91 100644 --- a/dUgcServer/UgcJobs.cpp +++ b/dUgcServer/UgcJobs.cpp @@ -5,6 +5,7 @@ #include "json.hpp" +#include "NifFile.h" #include "Sd0.h" #include "UgcFormats.h" #include "UgcModel.h" @@ -101,12 +102,6 @@ namespace UgcJobs { std::vector models; std::vector> opaquePieces, transparentPieces; UgcModel::Model preview; // LOD 0 before the lighting bake, for the dashboard - // The icon: LU Toolbox's icon renderer imports LOD 0 again, with its own color corrections and no variation. - // The same bricks in the same order, so LOD 0's hidden surface removal and occlusion apply to it too (neither - // changes what the icon's camera sees). - UgcModel::Model iconModel; - std::vector iconAo; - double iconMs = 0; for (size_t i = 0; i < lods.size(); i++) { auto options = settings.build; options.seed = seed; @@ -135,22 +130,10 @@ namespace UgcJobs { if (!outcome.note.empty()) outcome.note += "; "; outcome.note += "removed " + std::to_string(optimized.trianglesRemoved) + " of " + std::to_string(optimized.trianglesBefore) + " triangles"; } - if (i == 0) { - preview = model; - step = std::chrono::steady_clock::now(); - auto iconOptions = settings.build; - iconOptions.seed = seed; - iconOptions.lod = 0; - iconOptions.icon = settings.iconCorrectColors; - iconOptions.colorVariation = settings.iconColorVariation; - iconModel = UgcModel::Build(parts, library, iconOptions); - if (!optimized.kept.empty() && iconModel.opaque.TriangleCount() == optimized.kept.size()) UgcModel::KeepTriangles(iconModel.opaque, optimized.kept); - iconMs += Since(step); - } + if (i == 0) preview = model; step = std::chrono::steady_clock::now(); - auto ao = UgcRender::BakeAo(model, settings.ao); + UgcRender::BakeAo(model, settings.ao); aoMs += Since(step); - if (i == 0) iconAo = std::move(ao); entry["opaqueAfter"] = model.opaque.TriangleCount(); entry["vertices"] = model.opaque.positions.size() + model.transparent.positions.size(); opaquePieces.push_back(UgcModel::Divide(model.opaque)); @@ -185,9 +168,20 @@ namespace UgcJobs { outcome.files["model.noao.nif"] = UgcFormats::WriteLodNif("SceneNode_Model", groups(1, opaque, transparent)); } + // The icon is drawn from the .nif just made (its most detailed LOD, read back like any client .nif), so it + // shows what the game shows: the colors with their variation, hidden faces removed, the baked lighting. The + // lighting is in its vertex colors already, so the icon adds no occlusion of its own. const auto iconStart = std::chrono::steady_clock::now(); - AddIcon(outcome.files, iconModel, settings.icon, iconAo.empty() ? nullptr : &iconAo); - iconMs += Since(iconStart); + std::string nifError; + const auto readBack = NifFile::Parse(nif, 0, nifError); + if (!readBack) { + outcome.error = "the .nif made can't be read back for the icon: " + nifError; + return outcome; + } + auto iconOptions = settings.icon; + iconOptions.ao.enabled = false; + AddIcon(outcome.files, UgcModel::FromNif(*readBack), iconOptions); + const double iconMs = Since(iconStart); stats["ms"] = { { "build", std::lround(buildMs) }, { "hiddenSurfaces", std::lround(hsrMs) }, { "ambientOcclusion", std::lround(aoMs) }, { "icon", std::lround(iconMs) }, { "total", std::lround(Since(started)) } }; diff --git a/dUgcServer/UgcJobs.h b/dUgcServer/UgcJobs.h index 3b34ea706..df49562ae 100644 --- a/dUgcServer/UgcJobs.h +++ b/dUgcServer/UgcJobs.h @@ -27,9 +27,7 @@ namespace UgcJobs { bool combineTransparent{ false }; // one shape for all transparent bricks, else one per brick (Combine Transparent) UgcRender::OptimizeOptions optimize; // hidden surface removal UgcRender::AoOptions ao; // Bake Lighting (AO Only) - UgcRender::IconOptions icon; - bool iconCorrectColors{ true }; // the icon renderer's Correct Colors - float iconColorVariation{ 0.0f }; // percent; its Apply Color Variation is off + UgcRender::IconOptions icon; // player models: drawn from their .nif (LOD 0); its ao is not used UgcRender::IconOptions modularIcon; uint32_t maxBricks{}; // a model with more fails; 0: no limit }; diff --git a/dUgcServer/UgcServer.cpp b/dUgcServer/UgcServer.cpp index e9e32f621..8af4ec82d 100644 --- a/dUgcServer/UgcServer.cpp +++ b/dUgcServer/UgcServer.cpp @@ -101,8 +101,6 @@ namespace { settings.icon.shadows = Setting("icon_shadows", 1) != 0; settings.icon.ao.enabled = Setting("icon_ao", 1) != 0; settings.icon.ao.distance = settings.ao.distance; - settings.iconCorrectColors = Setting("icon_correct_colors", 1) != 0; - settings.iconColorVariation = std::clamp(Setting("icon_color_variation", 0.0f), 0.0f, 100.0f); settings.modularIcon = settings.icon; settings.modularIcon.yawDegrees = Setting("modular_icon_yaw", 53.36f); settings.modularIcon.pitchDegrees = Setting("modular_icon_pitch", 19.54f); diff --git a/docs/UgcServer.md b/docs/UgcServer.md index 53c5b8422..0b7750bb2 100644 --- a/docs/UgcServer.md +++ b/docs/UgcServer.md @@ -73,12 +73,13 @@ defaults. The table below goes through it step by step. blending. Opaque shapes are divided at 65535 vertices along their longest side (LU Toolbox's divide_mesh); transparent bricks are one shape each unless `combine_transparent=1`. Vertices are in LDD's Y-up space with identity transforms, like the game's own brick models. -7. The icon is rendered like LU Toolbox's icon renderer (its UGC render add-on's BrickBuild scene) by a software - rasterizer (no GPU, no display), 4x4 supersampled: LOD 0 with the icon's color corrections (white and black toned - down) and no color variation, a 50 mm lens (39.6 degrees) from 53.4 degrees around and 19.5 above, framed at 1.03, - a sun of strength 2.5 from 21 degrees around and 50.3 above with soft shadows, and a grey (0.192) world light - darkened by the ambient occlusion, on a transparent background: `icon.png` for the dashboard and a 32-bit - `icon.dds` for the client. +7. The icon is drawn from the finished `.nif`: it is read back with the same reader as the client's files (NifFile, + LOD 0) and rasterized, so it shows exactly what the game shows, with the color variation, the faces that were + removed and the lighting baked into the vertex colors (so the icon adds no occlusion of its own). The camera and + lights are LU Toolbox's icon renderer's (its UGC render add-on's BrickBuild scene), drawn by a software rasterizer + (no GPU, no display), 4x4 supersampled: a 50 mm lens (39.6 degrees) from 53.4 degrees around and 19.5 above, + framed at 1.03, a sun of strength 2.5 from 21 degrees around and 50.3 above with soft shadows, and a grey (0.192) + world light, on a transparent background: `icon.png` for the dashboard and a 32-bit `icon.dds` for the client. 8. `stats.json` records the bricks, each LOD's triangles before and after hidden faces were removed, vertices, shapes, how long each step took and the settings used; `model.noao.nif` is LOD 0 before the lighting bake. Both, and the icon and mesh of the version before (`previous.*`), are for the dashboard's viewer. @@ -108,9 +109,9 @@ defaults. The table below goes through it step by step. | Bake Lighting, AO Only: 64 AO samples, distance 5, transparent skipped, glow strength 3 x 2, smooth vertex colors | Same (`ao_samples`, `ao_distance`, `glow_strength`); smoothing averages a vertex's corners, and the occlusion is per vertex already | | NifTools export for LU: 20.3.0.9, user version 0 | Same | | Physics (`.hkx`) | Intentionally not done: `.hkx` requests answer 404, so the client makes its own | -| Icon: LOD 0, no hidden face removal, its own color corrections, no color variation | Same (`icon_correct_colors`, `icon_color_variation=0`); LOD 0's hidden face removal is reused (it doesn't change what the camera sees) | +| Icon: LOD 0 imported again with its own color corrections (white and black toned down) and no color variation | Different on purpose: the icon is drawn from the generated `.nif` (LOD 0), so it matches the game, variation and baked lighting included; no icon-only color corrections | | Icon: Bevel Edges and Subdivide | Not done (the rasterizer draws the bricks as they are) | -| Icon: principled materials (roughness 0.16), hashed transparency, Cycles | Approximated: diffuse sun with soft shadow-mapped shadows plus world light with ambient occlusion; no highlights or bounced light; transparent bricks sorted and blended | +| Icon: principled materials (roughness 0.16), hashed transparency, Cycles | Approximated: diffuse sun with soft shadow-mapped shadows plus world light (player models: their baked lighting; cars and rockets: ambient occlusion, `icon_ao`); no highlights or bounced light; transparent bricks sorted and blended | | Icon scene BrickBuild / Car: 50 mm lens, camera 53.4 / 19.5 degrees, sun 2.5 at 21 / 50.3, world 0.192, 128 px, framing 1.03, transparent film | Same (`icon_*`, `modular_icon_*`) | | Icon scene Rocket: 35 mm lens, other angles, two suns | Not done: rockets use the car camera | diff --git a/resources/ugcconfig.ini b/resources/ugcconfig.ini index e8e3b06c7..5303e5dc0 100644 --- a/resources/ugcconfig.ini +++ b/resources/ugcconfig.ini @@ -80,8 +80,9 @@ glow_strength=6 # Icons, set up like LU Toolbox's icon renderer: size in pixels, the camera's angle around (yaw, from the model's # front) and above (pitch) in degrees, its field of view, the empty border (1 fills the icon), the sun's angles and -# strength, the world light, shadows and ambient occlusion, its color corrections and color variation (percent). -# Modular builds (cars, rockets) have their own camera angles. +# strength, the world light and shadows. A player model's icon is drawn from its finished .nif (LOD 0), so it shows +# its colors, variation and baked lighting exactly as the game does. icon_ao: ambient occlusion for car and rocket +# icons (player models have theirs baked in). Modular builds (cars, rockets) have their own camera angles. icon_size=128 icon_yaw=53.36 icon_pitch=19.54 @@ -93,7 +94,5 @@ icon_sun_strength=2.5 icon_ambient=0.192 icon_shadows=1 icon_ao=1 -icon_correct_colors=1 -icon_color_variation=0 modular_icon_yaw=53.36 modular_icon_pitch=19.54 diff --git a/tests/dUgcTests/UgcTests.cpp b/tests/dUgcTests/UgcTests.cpp index ee927e1da..1b40f4082 100644 --- a/tests/dUgcTests/UgcTests.cpp +++ b/tests/dUgcTests/UgcTests.cpp @@ -566,6 +566,10 @@ TEST(UgcJobs, MakesLodsStatsAndIcons) { const auto far = NifFile::Parse(outcome.files.at("model.nif"), 1, error); ASSERT_TRUE(far) << error; EXPECT_TRUE(far->nodes.contains("LOD_2")); + // The icon is the .nif's LOD 0, drawn with the icon camera and no occlusion of its own + auto iconOptions = settings.icon; + iconOptions.ao.enabled = false; + EXPECT_EQ(outcome.files.at("icon.png"), UgcFormats::EncodePng(UgcRender::RenderIcon(UgcModel::FromNif(*nif), iconOptions))); // The same colors when made again EXPECT_EQ(UgcJobs::ProcessModel(LXFML5, library, settings, 99).files.at("model.nif"), outcome.files.at("model.nif"));