From c39b7ee935f1bc72964f978ff24fd861ae420637 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Mon, 28 Sep 2026 09:55:00 -0500 Subject: [PATCH] feat(ugc): opt-in metal and glow shader groups in made models Player models are multishader (RenderComponent shader 100): the client wraps each NiLODNode and draws it with the mapShaders id in its name. With shader_metal, shader_brushed or shader_glow set, the opaque bricks are split by look into S_Metal_Model, S_Brushed_Model and S_Glow_Model beside S01_Opaque_Model and S01_Alpha_Model, each with every LOD level. Metal is LU Toolbox's metallic colors plus Materials.xml types (shinySteel; brushedSteel and matteSteel for brushed), glow its glow colors. Glow shapes get an emissive material (glow_emissive) and their plain color, not the baked one. Transparent glow stays in S01_Alpha. All off by default, which writes the same bytes as before (tested). Not how live looked; models already made change only when made again. The icon renderer reads the groups back by tag and draws glow at its plain color and metal with a tinted reflection and highlight. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/routes/SettingsCatalog.cpp | 8 + dUgcServer/UgcBricks.cpp | 2 +- dUgcServer/UgcBricks.h | 5 +- dUgcServer/UgcFormats.cpp | 40 ++-- dUgcServer/UgcFormats.h | 6 +- dUgcServer/UgcJobs.cpp | 93 +++++++-- dUgcServer/UgcJobs.h | 30 ++- dUgcServer/UgcModel.cpp | 52 ++++- dUgcServer/UgcModel.h | 36 +++- dUgcServer/UgcProcessor.cpp | 4 +- dUgcServer/UgcRender.cpp | 24 ++- dUgcServer/UgcRender.h | 5 + dUgcServer/UgcServer.cpp | 18 ++ docs/UgcServer.md | 53 ++++- resources/ugcconfig.ini | 18 ++ tests/dUgcTests/UgcTests.cpp | 211 +++++++++++++++++++- 16 files changed, 560 insertions(+), 45 deletions(-) diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 033b4a71d..3c1e42ae0 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -450,6 +450,14 @@ namespace { c.Add(Float(UGC, "lod_cull", "Drawn up to", "", "10000", 0, 1000000)); c.Add(Text(UGC, "shader_opaque", "Opaque shader", "S_Opaque_Model.", "01")); c.Add(Bool(UGC, "combine_transparent", "One shape for all transparent bricks", "Off: each transparent brick is its own shape, so the client can sort them.", false)); + // Metal and glow groups (UgcJobs::Shaders): off keeps the files exactly as before, as live made them + const std::string notLive = " Not how live looked: live's models were all LEGO plastic (S01). Models already made keep their look until they are made again (Make everything again, or Reprocess)."; + c.Add(Int(UGC, "shader_metal", "Metal shader", "mapShaders id for metal colors (Materials.xml shinySteel and LU Toolbox's metallic ones), in a group S_Metal_Model: 88 is Polished Metal. 0: off, they stay LEGO plastic." + notLive, "0", 0, 9999)); + c.Add(Int(UGC, "shader_brushed", "Brushed steel shader", "mapShaders id for brushed steel colors (Materials.xml brushedSteel and matteSteel; the client's has none) in S_Brushed_Model: 89 is Brushed Steel. 0: off." + notLive, "0", 0, 9999)); + c.Add(Int(UGC, "shader_glow", "Glow shader", "mapShaders id for opaque glowing colors (LU Toolbox's glow colors) in S_Glow_Model, with their plain color and an emissive material: 46 is LEGO-Emissive. Transparent glow stays with the transparent bricks. 0: off." + notLive, "0", 0, 9999)); + c.Add(Float(UGC, "glow_emissive", "Glow emissive strength", "With the glow shader on: the glow shapes' material emissive, how far the emissive shader goes from lit to the plain color (1: fully).", "1", 0, 10)); + c.Add(Text(UGC, "metal_material_types", "Metal material types", "Materials.xml MaterialTypes drawn as metal, comma separated (none: only LU Toolbox's metallic colors).", "shinySteel")); + c.Add(Text(UGC, "brushed_material_types", "Brushed steel material types", "Materials.xml MaterialTypes drawn as brushed steel, comma separated (none: no such colors).", "brushedSteel,matteSteel")); 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")); diff --git a/dUgcServer/UgcBricks.cpp b/dUgcServer/UgcBricks.cpp index 3cc1e3609..025c67ad0 100644 --- a/dUgcServer/UgcBricks.cpp +++ b/dUgcServer/UgcBricks.cpp @@ -69,7 +69,7 @@ namespace UgcBricks { const auto channel = [element](const char* name, uint32_t fallback) { return static_cast(std::min(element->UnsignedAttribute(name, fallback), 255)); }; - materials[id] = Material{ channel("Red", 160), channel("Green", 160), channel("Blue", 160), channel("Alpha", 255) }; + materials[id] = Material{ channel("Red", 160), channel("Green", 160), channel("Blue", 160), channel("Alpha", 255), element->Attribute("MaterialType") ? element->Attribute("MaterialType") : "" }; } return materials; } diff --git a/dUgcServer/UgcBricks.h b/dUgcServer/UgcBricks.h index 1bb82fbfd..1c01d54c8 100644 --- a/dUgcServer/UgcBricks.h +++ b/dUgcServer/UgcBricks.h @@ -26,19 +26,20 @@ namespace UgcBricks { std::vector indices; }; - // An LDD material: sRGB color and opacity, 0-255 + // An LDD material: sRGB color and opacity, 0-255, and its MaterialType (shinyPlastic, shinySteel, glitter, ...) struct Material { uint8_t r{ 160 }; uint8_t g{ 160 }; uint8_t b{ 160 }; uint8_t a{ 255 }; + std::string type; bool Transparent() const { return a < 255; } }; // A .g file ("10GB" magic, counts, positions, normals, texture coordinates for decorated parts, indices) std::optional ParseGeometry(std::string_view data); - // Materials.xml: MatID -> color + // Materials.xml: MatID -> color and type std::map ParseMaterials(std::string_view xml); // A file from a zip archive (stored or deflated), matched without regard to case; nullopt when it isn't there diff --git a/dUgcServer/UgcFormats.cpp b/dUgcServer/UgcFormats.cpp index bc62077e7..c688c6722 100644 --- a/dUgcServer/UgcFormats.cpp +++ b/dUgcServer/UgcFormats.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include "MD5.h" #include "ZCompression.h" @@ -204,15 +205,7 @@ namespace { class SharedProperties { public: explicit SharedProperties(NifBuilder& nif) : m_Nif(nif) { - Writer material; - WriteNet(material, -1); - for (int i = 0; i < 3; i++) material.Float(1.0f); // ambient - for (int i = 0; i < 3; i++) material.Float(1.0f); // diffuse - for (int i = 0; i < 3; i++) material.Float(0.0f); // specular - for (int i = 0; i < 3; i++) material.Float(0.0f); // emissive - material.Float(4.0f); // glossiness, as the game's brick models - material.Float(1.0f); // alpha - m_Material = nif.Add("NiMaterialProperty", std::move(material.Data())); + m_Material = nif.Add("NiMaterialProperty", Material(0.0f)); Writer vertexColor; WriteNet(vertexColor, -1); @@ -220,8 +213,22 @@ namespace { m_VertexColor = nif.Add("NiVertexColorProperty", std::move(vertexColor.Data())); } - // An NiTriShape of `mesh` (-1 when it is empty or too big for the format) - int32_t Shape(const std::string& name, const UgcModel::Mesh* mesh, bool transparent) { + // A white NiMaterialProperty with this emissive color (grey) + static std::string Material(float emissive) { + Writer material; + WriteNet(material, -1); + for (int i = 0; i < 3; i++) material.Float(1.0f); // ambient + for (int i = 0; i < 3; i++) material.Float(1.0f); // diffuse + for (int i = 0; i < 3; i++) material.Float(0.0f); // specular + for (int i = 0; i < 3; i++) material.Float(emissive); + material.Float(4.0f); // glossiness, as the game's brick models + material.Float(1.0f); // alpha + return std::move(material.Data()); + } + + // An NiTriShape of `mesh` (-1 when it is empty or too big for the format); `emissive`: its material's + // emissive color, 0 for the shared material without one + int32_t Shape(const std::string& name, const UgcModel::Mesh* mesh, bool transparent, float emissive = 0.0f) { if (!mesh || mesh->Empty() || mesh->positions.size() > 65535 || mesh->TriangleCount() > 65535) return -1; // The properties every shape of the game's own brick models has, in their order: material, alpha (blending // by the vertex alpha: 1 on opaque bricks), specular (off) and vertex colors @@ -237,7 +244,13 @@ namespace { m_Specular = m_Nif.Add("NiSpecularProperty", std::move(specular.Data())); } (void)transparent; - std::vector properties{ m_Material, m_Alpha, m_Specular, m_VertexColor }; + int32_t material = m_Material; + if (emissive > 0.0f) { + auto [it, added] = m_Emissive.try_emplace(emissive, -1); + if (added) it->second = m_Nif.Add("NiMaterialProperty", Material(emissive)); + material = it->second; + } + std::vector properties{ material, m_Alpha, m_Specular, m_VertexColor }; const auto shapeBlock = m_Nif.Reserve("NiTriShape"); const auto dataBlock = m_Nif.Add("NiTriShapeData", TriShapeData(*mesh)); Writer tri; @@ -257,6 +270,7 @@ namespace { int32_t m_VertexColor{ -1 }; int32_t m_Alpha{ -1 }; int32_t m_Specular{ -1 }; + std::map m_Emissive; // emissive color -> its material }; } @@ -290,7 +304,7 @@ namespace UgcFormats { const auto level = nif.Reserve("NiNode"); std::vector shapes; for (const auto* piece : lod.pieces) { - const auto block = properties.Shape(group.name, piece, group.transparent); + const auto block = properties.Shape(group.name, piece, group.transparent, group.emissive); if (block >= 0) shapes.push_back(block); } nif.Fill(level, NodeData(nif.String(lod.name), shapes)); diff --git a/dUgcServer/UgcFormats.h b/dUgcServer/UgcFormats.h index f8aabbb82..e33ff4a8d 100644 --- a/dUgcServer/UgcFormats.h +++ b/dUgcServer/UgcFormats.h @@ -40,12 +40,16 @@ namespace UgcFormats { std::string name; // S01_Opaque_Model: the NiLODNode and its shapes bool transparent{}; std::vector lods; + // NiMaterialProperty's emissive color (grey) of the group's shapes; 0 the shared white material with none. + // The client's emissive shader (S46) lerps from the lit color to the vertex color by vertex alpha times its red. + float emissive{}; }; /** * The layout LU Toolbox exports (setup_lod_data) and the game's own brick models (res/BrickModels/ndmade) have: * the root node, an NiLODNode per group with NiRangeLODData holding each level's distances, a node per level and - * the level's shapes under it, named like the group. Properties as WriteNif. + * the level's shapes under it, named like the group. Properties as WriteNif; a group with + * an emissive color gets a material of its own. */ std::string WriteLodNif(const std::string& rootName, const std::vector& groups); diff --git a/dUgcServer/UgcJobs.cpp b/dUgcServer/UgcJobs.cpp index 95e80bc76..1d7b1c57a 100644 --- a/dUgcServer/UgcJobs.cpp +++ b/dUgcServer/UgcJobs.cpp @@ -65,15 +65,51 @@ namespace UgcJobs { return std::chrono::duration(std::chrono::steady_clock::now() - start).count(); } - std::string ShapeName(const std::string& shader, bool transparent) { - return ("S" + (transparent ? std::string("01") : shader) + (transparent ? "_Alpha_" : "_Opaque_") + "Model").substr(0, 60); + // An opaque mesh's pieces by look ([eLook]: UgcModel::Divide's pieces), the looks without a shader of their own + // with the plastic ones + using LookPieces = std::array, UgcModel::LOOK_COUNT>; + LookPieces DivideByLook(const UgcModel::Mesh& mesh, const std::array& separate) { + LookPieces pieces; + const auto split = UgcModel::SplitLooks(mesh, separate); + if (!split) { + pieces[0] = UgcModel::Divide(mesh); + return pieces; + } + for (size_t look = 0; look < UgcModel::LOOK_COUNT; look++) pieces[look] = UgcModel::Divide((*split)[look]); + return pieces; } } - bool IconFromNif(const std::string& nif, const UgcRender::IconOptions& options, UgcStorage::Files& files, std::string& error) { + uint32_t Shaders::TagOf(UgcModel::eLook look) const { + switch (look) { + case UgcModel::eLook::METAL: return metal; + case UgcModel::eLook::BRUSHED: return brushed; + case UgcModel::eLook::GLOW: return glow; + default: return 0; + } + } + + std::map Shaders::TagLooks() const { + std::map looks{ { 88, UgcModel::eLook::METAL }, { 89, UgcModel::eLook::BRUSHED }, { 46, UgcModel::eLook::GLOW } }; + for (const auto look : { UgcModel::eLook::METAL, UgcModel::eLook::BRUSHED, UgcModel::eLook::GLOW }) { + if (const auto tag = TagOf(look); tag != 0) looks[static_cast(tag)] = look; + } + return looks; + } + + std::string ShapeName(const Settings& settings, UgcModel::eLook look, bool transparent) { + if (transparent) return "S01_Alpha_Model"; + if (look == UgcModel::eLook::PLASTIC) return ("S" + settings.shaderOpaque + "_Opaque_Model").substr(0, 60); + const auto tag = std::to_string(settings.shaders.TagOf(look)); + const char* name = look == UgcModel::eLook::METAL ? "_Metal_Model" : look == UgcModel::eLook::BRUSHED ? "_Brushed_Model" : "_Glow_Model"; + return ("S" + std::string(tag.size() < 2 ? "0" : "") + tag + name).substr(0, 60); + } + + bool IconFromNif(const std::string& nif, const UgcRender::IconOptions& options, UgcStorage::Files& files, std::string& error, + const std::map& tagLooks) { const auto readBack = NifFile::Parse(nif, 0, error); if (!readBack) return false; - AddIcon(files, UgcModel::FromNif(*readBack), options); + AddIcon(files, UgcModel::FromNif(*readBack, tagLooks), options); return true; } @@ -113,7 +149,12 @@ namespace UgcJobs { // Every LOD made like LU Toolbox makes each LOD collection: colored, hidden faces removed, lighting baked, divided std::vector models; - std::vector> opaquePieces, transparentPieces; + std::vector opaquePieces; + std::vector> transparentPieces; + // The looks with a shader of their own (UgcJobs::Shaders), each an NiLODNode apart from the plastic + std::array separate{}; + for (size_t look = 1; look < UgcModel::LOOK_COUNT; look++) separate[look] = settings.shaders.TagOf(static_cast(look)) != 0; + const bool glowApart = separate[static_cast(UgcModel::eLook::GLOW)]; UgcModel::Model preview; // LOD 0 before the lighting bake, for the dashboard for (size_t i = 0; i < lods.size(); i++) { auto options = settings.build; @@ -144,27 +185,50 @@ namespace UgcJobs { outcome.note += "removed " + std::to_string(optimized.trianglesRemoved) + " of " + std::to_string(optimized.trianglesBefore) + " triangles"; } if (i == 0) preview = model; + // The emissive shader lerps from its own lighting to the vertex color, so glowing bricks keep their plain + // color: no occlusion, and no glow added (it would glow twice) + std::vector plainColors; + if (glowApart && !model.opaque.looks.empty()) plainColors = model.opaque.colors; step = std::chrono::steady_clock::now(); UgcRender::BakeAo(model, settings.ao); aoMs += Since(step); + for (size_t v = 0; v < plainColors.size() && v < model.opaque.looks.size(); v++) { + if (model.opaque.looks[v] == UgcModel::eLook::GLOW) model.opaque.colors[v] = glm::vec4(glm::vec3(plainColors[v]), 1.0f); + } entry["opaqueAfter"] = model.opaque.TriangleCount(); entry["vertices"] = model.opaque.positions.size() + model.transparent.positions.size(); - opaquePieces.push_back(UgcModel::Divide(model.opaque)); + opaquePieces.push_back(DivideByLook(model.opaque, separate)); transparentPieces.push_back(settings.combineTransparent ? UgcModel::Divide(model.transparent) : UgcModel::SplitAt(model.transparent, model.transparentBricks)); - entry["shapes"] = opaquePieces.back().size() + transparentPieces.back().size(); + size_t shapes = transparentPieces.back().size(); + for (const auto& pieces : opaquePieces.back()) shapes += pieces.size(); + entry["shapes"] = shapes; + // Triangles per group (NiLODNode) when metal or glow have groups of their own + if (std::find(separate.begin(), separate.end(), true) != separate.end()) { + auto& byGroup = entry["groups"] = nlohmann::json::object(); + for (size_t look = 0; look < UgcModel::LOOK_COUNT; look++) { + size_t triangles = 0; + for (const auto& piece : opaquePieces.back()[look]) triangles += piece.TriangleCount(); + if (triangles > 0) byGroup[ShapeName(settings, static_cast(look), false)] = triangles; + } + if (!model.transparent.Empty()) byGroup[ShapeName(settings, UgcModel::eLook::PLASTIC, true)] = model.transparent.TriangleCount(); + } lodStats.push_back(entry); } outcome.aoBaked = settings.ao.enabled; - // An NiLODNode for the opaque bricks and one for the transparent ones, as LU Toolbox names them - const auto groups = [&](size_t levels, const std::vector>& opaque, const std::vector>& transparent) { + // An NiLODNode for the opaque bricks and one for the transparent ones, as LU Toolbox names them, and one for + // each look with a shader of its own between them. Every group has every level (empty where it has nothing). + const auto groups = [&](size_t levels, const std::vector& opaque, const std::vector>& transparent) { std::vector out; - for (const bool isTransparent : { false, true }) { - UgcFormats::NifLodGroup group{ ShapeName(settings.shaderOpaque, isTransparent), isTransparent, {} }; + for (size_t kind = 0; kind <= UgcModel::LOOK_COUNT; kind++) { + const bool isTransparent = kind == UgcModel::LOOK_COUNT; + const auto look = isTransparent ? UgcModel::eLook::PLASTIC : static_cast(kind); + UgcFormats::NifLodGroup group{ ShapeName(settings, look, isTransparent), isTransparent, {} }; + if (look == UgcModel::eLook::GLOW) group.emissive = std::max(settings.shaders.glowEmissive, 0.0f); bool any = false; for (size_t i = 0; i < levels; i++) { UgcFormats::NifLod lod{ ranges[i].first, ranges[i].second, "LOD_" + std::to_string(lods[i]), {} }; - for (const auto& piece : (isTransparent ? transparent : opaque)[i]) lod.pieces.push_back(&piece); + for (const auto& piece : (isTransparent ? transparent[i] : opaque[i][kind])) lod.pieces.push_back(&piece); any = any || !lod.pieces.empty(); group.lods.push_back(std::move(lod)); } @@ -177,7 +241,8 @@ namespace UgcJobs { // LXFML is served from the database. AddDownload(outcome.files, "model.nif", nif); { - const std::vector> opaque{ UgcModel::Divide(preview.opaque) }, transparent{ transparentPieces[0] }; + const std::vector opaque{ DivideByLook(preview.opaque, separate) }; + const std::vector> transparent{ transparentPieces[0] }; outcome.files["model.noao.nif.gz"] = ZCompression::Gzip(UgcFormats::WriteLodNif("SceneNode_Model", groups(1, opaque, transparent))); } @@ -187,7 +252,7 @@ namespace UgcJobs { auto iconOptions = settings.icon; UgcIconParams::Apply(iconOptions, iconValues); std::string nifError; - if (!IconFromNif(nif, iconOptions, outcome.files, nifError)) { + if (!IconFromNif(nif, iconOptions, outcome.files, nifError, settings.shaders.TagLooks())) { outcome.error = "the .nif made can't be read back for the icon: " + nifError; return outcome; } diff --git a/dUgcServer/UgcJobs.h b/dUgcServer/UgcJobs.h index 7b202b26d..b62597a37 100644 --- a/dUgcServer/UgcJobs.h +++ b/dUgcServer/UgcJobs.h @@ -22,12 +22,31 @@ namespace UgcJobs { * How models are made. The defaults are LU Toolbox's (Process Model, Bake Lighting and the icon renderer), see the * parity table in docs/UgcServer.md. */ + /** + * The shaders of the metal and glow colors (UgcModel::eLook): the mapShaders id each look's own NiLODNode names + * (S88_Metal_Model, ...), 0 for none (the colors stay in S01_Opaque_Model, as on live). Not what live did: live's + * models are all S01 (docs/UgcServer.md, "Metal and glow"). + */ + struct Shaders { + uint32_t metal{}; // shader_metal: 88 Polished Metal + uint32_t brushed{}; // shader_brushed: 89 Brushed Steel + uint32_t glow{}; // shader_glow: 46 LEGO-Emissive + float glowEmissive{ 1.0f }; // glow_emissive: the glow shapes' NiMaterialProperty emissive (how much the vertex color shows unlit) + + // The mapShaders id of a look's group, 0 for the plastic S01_Opaque_Model + uint32_t TagOf(UgcModel::eLook look) const; + // Multishader tag -> look, for reading the looks back out of a .nif (the icon): these settings' ids, and the + // client's Polished Metal (88), Brushed Steel (89) and LEGO-Emissive (46) for .nifs made with other settings + std::map TagLooks() const; + }; + struct Settings { UgcModel::BuildOptions build; // palette, color variation, transparent opacity std::vector lods{ 0, 2 }; // brickprimitives levels made (LU Toolbox imports LOD 0 and 2; the client has no 3) UgcModel::LodDistances lodDistances; 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 UgcRender::AoOptions ao; // Bake Lighting (AO Only) UgcRender::IconOptions icon; // from the icon_* settings (UgcIconParams); presets and overrides go over it @@ -56,9 +75,14 @@ namespace UgcJobs { Outcome ProcessModel(const std::string& blob, UgcBricks::BrickLibrary& library, const Settings& settings, uint64_t seed = 0, const UgcIconParams::Values& iconValues = {}); - // A model's icon files (icon.png, icon.dds download) drawn from its .nif (LOD 0); false (and `error`) when the .nif - // can't be read - bool IconFromNif(const std::string& nif, const UgcRender::IconOptions& options, UgcStorage::Files& files, std::string& error); + // A model's icon files (icon.png, icon.dds download) drawn from its .nif (LOD 0), its metal and glow groups by + // `tagLooks` (Shaders::TagLooks); false (and `error`) when the .nif can't be read + bool IconFromNif(const std::string& nif, const UgcRender::IconOptions& options, UgcStorage::Files& files, std::string& error, + const std::map& tagLooks = {}); + + // The name of a group of shapes (its NiLODNode and shapes): S01_Opaque_Model, S01_Alpha_Model, S88_Metal_Model, + // S89_Brushed_Model, S46_Glow_Model (the ids from the settings), at most 60 characters as LU Toolbox cuts them + std::string ShapeName(const Settings& settings, UgcModel::eLook look, bool transparent); // How many bricks (parts) an LXFML has, counted cheaply (for the memory estimate before a job starts) size_t CountParts(std::string_view lxfml); diff --git a/dUgcServer/UgcModel.cpp b/dUgcServer/UgcModel.cpp index 84e39e1e0..21b05c9c0 100644 --- a/dUgcServer/UgcModel.cpp +++ b/dUgcServer/UgcModel.cpp @@ -159,6 +159,11 @@ namespace UgcModel { if (other.glow.empty()) glow.resize(positions.size(), glm::vec3(0.0f)); else glow.insert(glow.end(), other.glow.begin(), other.glow.end()); } + if (!looks.empty() || !other.looks.empty()) { + looks.resize(base, eLook::PLASTIC); + if (other.looks.empty()) looks.resize(positions.size(), eLook::PLASTIC); + else looks.insert(looks.end(), other.looks.begin(), other.looks.end()); + } indices.reserve(indices.size() + other.indices.size()); for (const auto index : other.indices) indices.push_back(base + index); } @@ -185,11 +190,39 @@ namespace UgcModel { return any; } + eLook LookOf(uint32_t id, const UgcBricks::Material& material, const LookRules& rules) { + if (rules.paletteGlow && UgcPalette::Glow(id)) return eLook::GLOW; + if (rules.paletteMetallic && UgcPalette::IsMetallic(id)) return eLook::METAL; + const auto type = rules.materialTypes.find(material.type); + return type != rules.materialTypes.end() ? type->second : eLook::PLASTIC; + } + + std::optional> SplitLooks(const Mesh& mesh, const std::array& separate) { + if (mesh.looks.size() != mesh.positions.size()) return std::nullopt; + const auto lookOf = [&](size_t triangle) { + const auto look = mesh.looks[mesh.indices[triangle * 3]]; + return separate[static_cast(look)] ? look : eLook::PLASTIC; + }; + bool any = false; + for (size_t t = 0; t < mesh.TriangleCount() && !any; t++) any = lookOf(t) != eLook::PLASTIC; + if (!any) return std::nullopt; + std::array out; + for (size_t look = 0; look < LOOK_COUNT; look++) { + std::vector keep(mesh.TriangleCount()); + bool some = false; + for (size_t t = 0; t < keep.size(); t++) some = (keep[t] = static_cast(lookOf(t)) == look) || some; + if (!some) continue; + out[look] = mesh; + KeepTriangles(out[look], keep); + } + return out; + } + Model Build(const std::vector& parts, UgcBricks::BrickLibrary& library, const BuildOptions& options) { Model model; std::set missing; const bool luToolbox = options.palette == ePalette::LU_TOOLBOX; - bool anyGlow = false; + bool anyGlow = false, anyLook = false; for (uint32_t brick = 0; brick < parts.size(); brick++) { const auto& part = parts[brick]; const auto design = library.GetDesign(part.designId, options.lod); @@ -249,6 +282,8 @@ namespace UgcModel { } const glm::vec4 color(UgcPalette::LinearToSrgb(linear), alpha); anyGlow = anyGlow || glow != glm::vec3(0.0f); + const auto look = transparent ? eLook::PLASTIC : LookOf(colorId, library.GetMaterial(colorId), options.looks); + anyLook = anyLook || look != eLook::PLASTIC; const auto base = static_cast(mesh.positions.size()); const size_t vertexCount = geometry.positions.size() / 3; for (size_t v = 0; v < vertexCount; v++) { @@ -260,13 +295,17 @@ namespace UgcModel { mesh.positions.push_back(glm::vec3(part.transform * glm::vec4(position, 1.0f))); mesh.normals.push_back(normal); mesh.colors.push_back(color); - if (&mesh == &model.opaque) model.opaque.glow.push_back(glow); + if (&mesh == &model.opaque) { + model.opaque.glow.push_back(glow); + model.opaque.looks.push_back(look); + } } for (const auto i : geometry.indices) mesh.indices.push_back(base + i); } } if (!anyGlow) model.opaque.glow.clear(); else model.opaque.glow.resize(model.opaque.positions.size(), glm::vec3(0.0f)); + if (!anyLook) model.opaque.looks.clear(); model.missingDesigns.assign(missing.begin(), missing.end()); return model; } @@ -303,7 +342,7 @@ namespace UgcModel { return ranges; } - Model FromNif(const NifFile::Model& nif) { + Model FromNif(const NifFile::Model& nif, const std::map& tagLooks) { Model model; for (const auto& source : nif.meshes) { Mesh mesh; @@ -334,6 +373,9 @@ namespace UgcModel { // Blending only shows where something is see-through (the game's brick models blend every shape) bool seeThrough = source.material.alphaBlend && source.material.alpha < 0.99f; for (size_t v = 0; source.material.alphaBlend && !seeThrough && v < mesh.colors.size(); v++) seeThrough = mesh.colors[v].a < 0.99f; + if (const auto look = tagLooks.find(source.material.shaderTag); !seeThrough && look != tagLooks.end() && look->second != eLook::PLASTIC) { + mesh.looks.assign(mesh.positions.size(), look->second); + } (seeThrough ? model.transparent : model.opaque).Append(mesh); } return model; @@ -355,6 +397,7 @@ namespace UgcModel { if (source < mesh.normals.size()) piece.normals.push_back(mesh.normals[source]); if (source < mesh.colors.size()) piece.colors.push_back(mesh.colors[source]); if (source < mesh.glow.size()) piece.glow.push_back(mesh.glow[source]); + if (source < mesh.looks.size()) piece.looks.push_back(mesh.looks[source]); } piece.indices.push_back(it->second); } @@ -376,6 +419,7 @@ namespace UgcModel { if (source < mesh.normals.size()) kept.normals.push_back(mesh.normals[source]); if (source < mesh.colors.size()) kept.colors.push_back(mesh.colors[source]); if (source < mesh.glow.size()) kept.glow.push_back(mesh.glow[source]); + if (source < mesh.looks.size()) kept.looks.push_back(mesh.looks[source]); } kept.indices.push_back(remap[source]); } @@ -408,6 +452,7 @@ namespace UgcModel { if (source < mesh.normals.size()) current.normals.push_back(mesh.normals[source]); if (source < mesh.colors.size()) current.colors.push_back(mesh.colors[source]); if (source < mesh.glow.size()) current.glow.push_back(mesh.glow[source]); + if (source < mesh.looks.size()) current.looks.push_back(mesh.looks[source]); } current.indices.push_back(it->second); } @@ -460,6 +505,7 @@ namespace UgcModel { if (source < mesh.normals.size()) half.normals.push_back(mesh.normals[source]); if (source < mesh.colors.size()) half.colors.push_back(mesh.colors[source]); if (source < mesh.glow.size()) half.glow.push_back(mesh.glow[source]); + if (source < mesh.looks.size()) half.looks.push_back(mesh.looks[source]); } half.indices.push_back(remap[source]); } diff --git a/dUgcServer/UgcModel.h b/dUgcServer/UgcModel.h index dddce09ff..990f2f84a 100644 --- a/dUgcServer/UgcModel.h +++ b/dUgcServer/UgcModel.h @@ -1,6 +1,9 @@ #pragma once +#include #include +#include +#include #include #include #include @@ -32,11 +35,19 @@ namespace UgcModel { // Whether an LXFML reads but has no bricks at all (nothing to make; not a failure) bool HasNoBricks(std::string_view lxfml); + /** + * How an opaque color looks in the game when the UGC server's shader settings give it a shader of its own + * (docs/UgcServer.md, "Metal and glow"): the LEGO plastic of S01_Opaque_Model, polished metal, brushed steel or glow. + */ + enum class eLook : uint8_t { PLASTIC = 0, METAL, BRUSHED, GLOW }; + constexpr size_t LOOK_COUNT = 4; + struct Mesh { std::vector positions; std::vector normals; std::vector colors; // sRGB, 0..1, alpha is opacity std::vector glow; // linear glow color per vertex (LU Toolbox's "Glow" layer); empty when nothing glows + std::vector looks; // per vertex; empty when everything is plastic std::vector indices; size_t TriangleCount() const { return indices.size() / 3; } @@ -62,6 +73,19 @@ namespace UgcModel { BRICKDB, // the brick database's Materials.xml }; + /** + * Which colors have which look, from the client's data: a Materials.xml MaterialType (brickdb.zip) and LU Toolbox's + * metallic and glow colors (UgcPalette). Glow wins over metal; transparent bricks are always plastic. + */ + struct LookRules { + std::map materialTypes{ { "shinySteel", eLook::METAL }, { "brushedSteel", eLook::BRUSHED }, { "matteSteel", eLook::BRUSHED } }; + bool paletteMetallic{ true }; // LU Toolbox's Metallic colors (UgcPalette::IsMetallic) are METAL + bool paletteGlow{ true }; // its glow colors (UgcPalette::Glow) are GLOW + }; + + // The look of an opaque material `id` whose Materials.xml entry is `material` + eLook LookOf(uint32_t id, const UgcBricks::Material& material, const LookRules& rules); + struct BuildOptions { ePalette palette{ ePalette::LU_TOOLBOX }; float colorVariation{ 5.0f }; // percent, 0: none (LU Toolbox: Apply Color Variation, 5%) @@ -69,6 +93,7 @@ namespace UgcModel { float transparentOpacity{ 58.82f }; // percent, transparent bricks' vertex alpha (LU Toolbox palette only) bool icon{}; // the icon renderer's color corrections uint32_t lod{}; // brickprimitives level + LookRules looks; // which colors are metal and glow (Mesh::looks) }; /** @@ -100,8 +125,15 @@ namespace UgcModel { */ std::vector Divide(const Mesh& mesh, size_t maxVertices = 65535, size_t maxTriangles = 65535); - // A client .nif's meshes as one model (vertex colors times material color; transparent when blended) - Model FromNif(const NifFile::Model& nif); + // A client .nif's meshes as one model (vertex colors times material color; transparent when blended). `tagLooks`: + // the look of the opaque shapes whose multishader tag (NifFile::ShaderTag, a mapShaders id) is listed + Model FromNif(const NifFile::Model& nif, const std::map& tagLooks = {}); + + /** + * The mesh's triangles by look ([eLook] -> its triangles; a triangle's look is its first vertex's), the looks not + * in `separate` staying with PLASTIC. nullopt when nothing is separated: the mesh stays as it is. + */ + std::optional> SplitLooks(const Mesh& mesh, const std::array& separate); // The mesh cut into pieces at these index offsets (each piece's triangles start at one), e.g. one per brick std::vector SplitAt(const Mesh& mesh, const std::vector& starts); diff --git a/dUgcServer/UgcProcessor.cpp b/dUgcServer/UgcProcessor.cpp index fb628273c..fb689f6ce 100644 --- a/dUgcServer/UgcProcessor.cpp +++ b/dUgcServer/UgcProcessor.cpp @@ -209,7 +209,7 @@ void UgcProcessor::Worker() { const auto nif = m_Storage.ReadNif(Kind::MODEL, job.id, "model.nif"); auto options = settings.icon; UgcIconParams::Apply(options, job.iconValues); - outcome.ok = nif && UgcJobs::IconFromNif(*nif, options, outcome.files, outcome.error); + outcome.ok = nif && UgcJobs::IconFromNif(*nif, options, outcome.files, outcome.error, settings.shaders.TagLooks()); if (!nif) outcome.error = "the model has no stored .nif yet"; } if (outcome.ok && outcome.files.contains("assembly.nif")) { @@ -252,7 +252,7 @@ void UgcProcessor::Worker() { const auto nif = m_Storage.ReadNif(Kind::MODEL, job.id, "model.nif"); auto options = settings.icon; UgcIconParams::Apply(options, job.iconValues); - done.outcome.ok = nif && UgcJobs::IconFromNif(*nif, options, done.outcome.files, done.outcome.error); + done.outcome.ok = nif && UgcJobs::IconFromNif(*nif, options, done.outcome.files, done.outcome.error, settings.shaders.TagLooks()); if (!nif) done.outcome.error = "no stored .nif"; } else { done.outcome = job.kind == Kind::MODEL diff --git a/dUgcServer/UgcRender.cpp b/dUgcServer/UgcRender.cpp index 1258f4aa0..5a53dd9f0 100644 --- a/dUgcServer/UgcRender.cpp +++ b/dUgcServer/UgcRender.cpp @@ -483,8 +483,28 @@ namespace UgcRender { // The sun's highlight (Blinn-Phong), white, on top of the color const float highlight = direct > 0.0f ? options.specular * options.sunStrength / 3.14159265f * sun * std::pow(std::max(0.0f, glm::dot(normal, halfway)), std::max(options.shininess, 1.0f)) : 0.0f; const float exposure = std::max(options.exposure, 0.0f); - return glm::vec4((ToLinear(base.r) * lighting + highlight) * exposure, (ToLinear(base.g) * lighting + highlight) * exposure, - (ToLinear(base.b) * lighting + highlight) * exposure, std::clamp(base.a, 0.0f, 1.0f)); + const auto look = isOpaque && mesh.looks.size() == mesh.positions.size() ? mesh.looks[i0] : UgcModel::eLook::PLASTIC; + if (look == UgcModel::eLook::PLASTIC) { + return glm::vec4((ToLinear(base.r) * lighting + highlight) * exposure, (ToLinear(base.g) * lighting + highlight) * exposure, + (ToLinear(base.b) * lighting + highlight) * exposure, std::clamp(base.a, 0.0f, 1.0f)); + } + const glm::vec3 linear(ToLinear(base.r), ToLinear(base.g), ToLinear(base.b)); + glm::vec3 shaded = (linear * lighting + glm::vec3(highlight)) * exposure; + if (look == UgcModel::eLook::METAL || look == UgcModel::eLook::BRUSHED) { + // A reflection of a bright sky over a dark ground, tinted by the color (polished: sharp; brushed: blurred + // and duller), over a dimmed diffuse light, and the sun's highlight in the metal's color + const bool polished = look == UgcModel::eLook::METAL; + const auto reflected = glm::reflect(-toCamera, normal); + const float up = polished ? glm::smoothstep(-0.15f, 0.5f, reflected.y) : 0.5f + 0.5f * reflected.y; + const float environment = glm::mix(0.06f, polished ? 1.1f : 0.75f, up); + const float spot = direct > 0.0f ? options.sunStrength / 3.14159265f * sun * + std::pow(std::max(0.0f, glm::dot(normal, halfway)), polished ? 180.0f : 30.0f) * (polished ? 4.0f : 1.2f) : 0.0f; + shaded = linear * (lighting * 0.35f + environment + spot) * exposure; + } else if (look == UgcModel::eLook::GLOW) { + // LEGO-Emissive: lerp(lit, vertex color, vertex alpha * the material's emissive red) + shaded = glm::mix(shaded, linear, std::clamp(options.glowEmissive, 0.0f, 1.0f)); + } + return glm::vec4(shaded, std::clamp(base.a, 0.0f, 1.0f)); }; // Opaque first, with the depth buffer diff --git a/dUgcServer/UgcRender.h b/dUgcServer/UgcRender.h index ecd2cbd71..65d412520 100644 --- a/dUgcServer/UgcRender.h +++ b/dUgcServer/UgcRender.h @@ -55,10 +55,15 @@ namespace UgcRender { float contrast{ 1.0f }; // around the middle grey of the sRGB result float shadows{ 1.0f }; // how much the sun's shadows darken, 0 to 1 AoOptions ao{ false, 5.0f, 32, 1.0f, 0.0f }; + float glowEmissive{ 1.0f }; // how far glowing shapes go from lit to their plain color (the glow_emissive setting) }; // The model drawn from the icon's camera, framed to fit, on a transparent background. `opaqueAo`: the opaque mesh's // ambient occlusion (AmbientOcclusion) when it is known already, else it is worked out when options.ao wants it. + // Opaque vertices with a look (UgcModel::Mesh::looks) are drawn roughly as the game's shaders draw them: GLOW goes + // from lit to its plain color by glowEmissive (LEGO-Emissive), METAL and BRUSHED dim the diffuse light and add a + // sky-and-ground reflection tinted by the color and a highlight, sharp for polished metal and broad for brushed + // steel (Polished Metal, Brushed Steel: an environment map tinted by the vertex color). Image RenderIcon(const UgcModel::Model& model, const IconOptions& options, const std::vector* opaqueAo = nullptr); struct OptimizeOptions { diff --git a/dUgcServer/UgcServer.cpp b/dUgcServer/UgcServer.cpp index c5f18b060..a2f9c54d4 100644 --- a/dUgcServer/UgcServer.cpp +++ b/dUgcServer/UgcServer.cpp @@ -88,6 +88,24 @@ namespace { settings.lodDistances.cull = Setting("lod_cull", 10000.0f); if (!Game::config->GetValue("shader_opaque").empty()) settings.shaderOpaque = Game::config->GetValue("shader_opaque"); settings.combineTransparent = Setting("combine_transparent", 0) != 0; + // Metal and glow colors in NiLODNodes of their own, drawn with those shaders (off by default: not how live looked) + settings.shaders.metal = std::min(Setting("shader_metal", 0), 9999u); + settings.shaders.brushed = std::min(Setting("shader_brushed", 0), 9999u); + settings.shaders.glow = std::min(Setting("shader_glow", 0), 9999u); + settings.shaders.glowEmissive = std::clamp(Setting("glow_emissive", 1.0f), 0.0f, 10.0f); + settings.icon.glowEmissive = settings.shaders.glowEmissive; + // Which Materials.xml MaterialTypes are metal and brushed steel + for (const auto& [key, look] : { std::pair{ "metal_material_types", UgcModel::eLook::METAL }, std::pair{ "brushed_material_types", UgcModel::eLook::BRUSHED } }) { + const auto value = Game::config->GetValue(key); + if (value.empty()) continue; + std::erase_if(settings.build.looks.materialTypes, [look](const auto& entry) { return entry.second == look; }); + std::stringstream stream(value); + std::string type; + while (std::getline(stream, type, ',')) { + std::erase_if(type, [](unsigned char c) { return std::isspace(c); }); + if (!type.empty() && type != "none") settings.build.looks.materialTypes[type] = look; + } + } 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); diff --git a/docs/UgcServer.md b/docs/UgcServer.md index 4242db67b..b59c1c0e5 100644 --- a/docs/UgcServer.md +++ b/docs/UgcServer.md @@ -192,7 +192,7 @@ defaults. The table below goes through it step by step. | 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 | | 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`); the glow, metal and superemissive shader settings aren't used by LU Toolbox either | +| 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) | | 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 | @@ -202,6 +202,57 @@ defaults. The table below goes through it step by step. | 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 | +### Metal and glow (opt in, not how live looked) + +Live's models, LU Toolbox's exports and the client's own builder (`LUNifBuilder_BK`, which writes only `S01_Opaque` +and `S01_Alpha`) all draw every brick with the LEGO shader, so metal colors look like grey plastic and glowing colors +like bright plastic. The UGC server can instead give them the client's metal and emissive shaders. Everything is off +by default, and off writes exactly the files it wrote before these settings existed (the same bytes, tested), so +nothing is made again needlessly. + +How the client picks the shader (checked in the 1.10.64 client; Ghidra bookmarks under "UGCShaders"): player models +(LOT 14, and 6662) have RenderComponent shader 100, mapShaders "Multishader" (gameValue 9999). For a downloaded model +`LWOBaseRenderComponent::WrapMultishaderNodes` (0x00c0d370) wraps each `NiLODNode` (or bare `NiGeometry`) of the +.nif, and `AddObjectToRenderPipe` (0x00cfbdb0) reads the wrapped node's name with `sscanf("S%d")` (else `"_S%d"` +after the first `_S`): the number is a mapShaders id, and its gameValue is the shader. A gameValue outside 3..108 +falls back to 5 (LEGO) and logs "Multishaded node ... malformed name". There is one shader per `NiLODNode`, shared by +all of its levels, so each look needs a group of its own. + +| Setting (`ugcconfig.ini`, dashboard: UGC models) | Default | What it writes | +| --- | --- | --- | +| `shader_metal` | 0 (off) | `S_Metal_Model` for metal colors: 88 is Polished Metal (gameValue 98). The client loads `textures/metal/metal_reflection_polished.dds` itself and tints it by the vertex color (`Metallic.fx`, `Technique_Lighting_PolishedMetal_VertColor`). | +| `shader_brushed` | 0 (off) | `S_Brushed_Model` for brushed steel colors: 89 is Brushed Steel (gameValue 99; it loads `metal_reflection_brushed.dds` and `_noise.dds`, the noise in object space). The client's Materials.xml has no such colors, so this only does something with a Materials.xml that names them. | +| `shader_glow` | 0 (off) | `S_Glow_Model` for opaque glowing colors: 46 is LEGO-Emissive (gameValue 53), which draws `lerp(lit, vertex color, vertex alpha * material emissive red)`, opaque. | +| `glow_emissive` | 1 | The glow shapes' `NiMaterialProperty` emissive (grey): how far the shader goes from lit to the plain color. | +| `metal_material_types` | `shinySteel` | Materials.xml `MaterialType`s that are metal (empty: the default; `none`: none). | +| `brushed_material_types` | `brushedSteel,matteSteel` | Materials.xml `MaterialType`s that are brushed steel. | + +Which color has which look is data, not a list in the code (`UgcModel::LookOf`): glow is LU Toolbox's glow table +(`UgcPalette::Glow`: 50, 294, 329, 9000-9027), metal is LU Toolbox's metallic table (`UgcPalette::IsMetallic`) plus +the Materials.xml types above (the clients checked have 8 or 14 `shinySteel` colors, and 1 or 3 `glitter` ones, which stay +plastic, as does pearl: the client has no shader for them). Only opaque bricks get a look: a transparent glowing +color (294 with the brick database palette, alpha 150) stays in `S01_Alpha_Model`. + +What is written with a group on: per LOD, the opaque bricks are split by look before being divided at 65535 vertices, +and the .nif gets, in order, `S01_Opaque_Model`, `S88_Metal_Model`, `S89_Brushed_Model`, `S46_Glow_Model` and +`S01_Alpha_Model`, each only when it has triangles, and each with every LOD level (an empty `LOD_` node where it has +none there), like the plastic groups. Metal shapes are like plastic ones (white material, no textures, the brick color +as vertex color with the lighting baked in). Glow shapes get a material of their own with emissive `glow_emissive`, +vertex alpha 1 (the shader's mask) and their plain color, not the baked one: the shader lights them itself, and the +glow added by the bake would glow twice. `stats.json` lists each LOD's triangles per group. `model.noao.nif` has the +same groups. + +Turning a setting on or off changes only models made afterwards: the ones made already keep their look until they are +made again, with the UGC page's **Make everything again** (or Reprocess on one model); nothing is remade on its own. + +The icon renderer and the dashboard know the groups: the icon reads each shape's tag back (the settings' ids and the +client's 88, 89 and 46) and draws glow at its plain color (by `glow_emissive`, unlit) and metal with a dimmed diffuse +light, a sky over dark ground reflection tinted by its color and a sun highlight (sharp for polished, broad for +brushed). This is an approximation of the game's environment maps. `NifFile::ShaderLookFor` gives 98 `REFLECTIVE`, +99 `REFLECTIVE | BRUSHED` and 53 `EMISSIVE`; the UGC page's 3D view gets each mesh's look (`/api/ugc/mesh`, "look") +and draws metal as reflective (metalness 1, the view's environment) and glow unlit, and the zone views draw +LEGO-Emissive objects going to their vertex color by its alpha (metal there stays lit like the rest). + Modular builds (`ugc_modular_build` rows, `ldf_config` like `1:4713+1:4714+1:4715`): 1. Each module LOT's `ModuleComponent` (component type 28) gives its part code and build diff --git a/resources/ugcconfig.ini b/resources/ugcconfig.ini index 74fa5c1d7..3ea60c6d2 100644 --- a/resources/ugcconfig.ini +++ b/resources/ugcconfig.ini @@ -64,6 +64,24 @@ lod_cull=10000 shader_opaque=01 combine_transparent=0 +# Metal and glow colors in NiLODNodes of their own, drawn with the client's metal and emissive shaders. Not how live +# looked: live's models were all LEGO plastic (S01), which is what 0 (off, the default) keeps, byte for byte. +# shader_metal: mapShaders id for metal colors (Materials.xml types in metal_material_types and LU Toolbox's metallic +# colors), S_Metal_Model: 88 is Polished Metal. +# shader_brushed: for brushed steel colors (brushed_material_types; the client's Materials.xml has none): 89 is +# Brushed Steel. +# shader_glow: for opaque glowing colors (LU Toolbox's glow colors), with their plain color and an emissive material: +# 46 is LEGO-Emissive. Transparent glow stays with the transparent bricks. +# glow_emissive: the glow shapes' material emissive (how far the shader goes from lit to the plain color, 1 fully). +# *_material_types: Materials.xml MaterialTypes, comma separated (empty: the default, none: no types). +# Models already made keep their look until they're made again (the dashboard's Make everything again, or Reprocess). +shader_metal=0 +shader_brushed=0 +shader_glow=0 +glow_emissive=1 +metal_material_types=shinySteel +brushed_material_types=brushedSteel,matteSteel + # 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_hidden_faces=1 diff --git a/tests/dUgcTests/UgcTests.cpp b/tests/dUgcTests/UgcTests.cpp index d6c1ae0e3..6de99012f 100644 --- a/tests/dUgcTests/UgcTests.cpp +++ b/tests/dUgcTests/UgcTests.cpp @@ -102,9 +102,11 @@ TEST(UgcBricks, ParsesGeometryAndRejectsBadData) { } TEST(UgcBricks, ParsesMaterials) { - const auto materials = UgcBricks::ParseMaterials(R"()"); + const auto materials = UgcBricks::ParseMaterials(R"()"); ASSERT_EQ(materials.size(), 2u); EXPECT_EQ(materials.at(21).r, 222); + EXPECT_EQ(materials.at(21).type, ""); + EXPECT_EQ(materials.at(40).type, "shinySteel"); EXPECT_FALSE(materials.at(21).Transparent()); EXPECT_TRUE(materials.at(40).Transparent()); } @@ -1057,3 +1059,210 @@ TEST(UgcIconPose, MatchesTheEditorsFixture) { } } } + +namespace { + // Plastic (21), LU Toolbox metallic (150), glow (329, and 294 which LU Toolbox's palette has opaque) and + // transparent (40) + const char* LOOKS_LXFML = R"( + + + + + + +)"; + + UgcJobs::Settings SmallSettings() { + UgcJobs::Settings settings; + settings.optimize.resolution = 128; + settings.ao.samples = 8; + settings.icon.size = 32; + settings.icon.supersample = 1; + settings.icon.ao.samples = 4; + return settings; + } +} + +TEST(UgcShaders, OffIsByteIdenticalToBefore) { + // With the shader settings off (the default) the files are exactly what the server made before they existed, so + // nothing is made again needlessly. The hashes are of the files made before the settings were added. + UgcBricks::BrickLibrary library(MakeRes(), 0); + const auto outcome = UgcJobs::ProcessModel(LOOKS_LXFML, library, SmallSettings(), 7); + ASSERT_TRUE(outcome.ok) << outcome.error; + const auto nif = *ZCompression::Gunzip(outcome.files.at("model.nif.gz")); + std::string error; + const auto read = NifFile::Parse(nif, 0, error); + ASSERT_TRUE(read) << error; + EXPECT_EQ(read->nodes.size(), 4u); // the root, S01_Opaque_Model, S01_Alpha_Model and LOD_0 + for (const auto& mesh : read->meshes) EXPECT_EQ(mesh.material.shaderTag, 1); + // The floating point results (color variation, occlusion) are the same on one platform and compiler; the hashes + // were taken with GCC on x86-64 Linux +#if defined(__linux__) && defined(__x86_64__) && defined(__GNUC__) && !defined(__clang__) + EXPECT_EQ(UgcFormats::Md5Hex(nif), "b0fcb707d36ccdb62e951bf50593e633"); + EXPECT_EQ(UgcFormats::Md5Hex(*ZCompression::Gunzip(outcome.files.at("model.noao.nif.gz"))), "db55bd2c8567a862b2c96942aa5a2617"); + EXPECT_EQ(UgcFormats::Md5Hex(outcome.files.at("icon.png")), "032ff7df236a636a4c609071d9b46181"); +#endif +} + +TEST(UgcShaders, OnlyTheShaderIdsSwitchItOn) { + // The other shader settings change nothing while the groups are off + UgcBricks::BrickLibrary library(MakeRes(), 0); + auto settings = SmallSettings(); + const auto before = UgcJobs::ProcessModel(LOOKS_LXFML, library, settings, 7); + settings.shaders.glowEmissive = 0.5f; + settings.icon.glowEmissive = 0.5f; + settings.build.looks.materialTypes.clear(); + const auto after = UgcJobs::ProcessModel(LOOKS_LXFML, library, settings, 7); + ASSERT_TRUE(before.ok && after.ok); + for (const auto* name : { "model.nif.checksum", "model.noao.nif.gz", "icon.png" }) EXPECT_EQ(before.files.at(name), after.files.at(name)) << name; +} + +TEST(UgcShaders, NamesTheGroups) { + UgcJobs::Settings settings; + settings.shaders.metal = 88; + settings.shaders.brushed = 89; + settings.shaders.glow = 7; + EXPECT_EQ(UgcJobs::ShapeName(settings, UgcModel::eLook::PLASTIC, false), "S01_Opaque_Model"); + EXPECT_EQ(UgcJobs::ShapeName(settings, UgcModel::eLook::PLASTIC, true), "S01_Alpha_Model"); + EXPECT_EQ(UgcJobs::ShapeName(settings, UgcModel::eLook::METAL, false), "S88_Metal_Model"); + EXPECT_EQ(UgcJobs::ShapeName(settings, UgcModel::eLook::BRUSHED, false), "S89_Brushed_Model"); + EXPECT_EQ(UgcJobs::ShapeName(settings, UgcModel::eLook::GLOW, false), "S07_Glow_Model"); + // The client reads the id back as the tag + EXPECT_EQ(NifFile::ShaderTag(UgcJobs::ShapeName(settings, UgcModel::eLook::GLOW, false)), 7); + EXPECT_EQ(NifFile::ShaderTag(UgcJobs::ShapeName(settings, UgcModel::eLook::METAL, false)), 88); + // The client's own ids read back to their looks whatever the settings, the settings' own too + const auto looks = settings.shaders.TagLooks(); + EXPECT_EQ(looks.at(88), UgcModel::eLook::METAL); + EXPECT_EQ(looks.at(46), UgcModel::eLook::GLOW); + EXPECT_EQ(looks.at(7), UgcModel::eLook::GLOW); + EXPECT_FALSE(looks.contains(1)); +} + +TEST(UgcShaders, LooksComeFromTheColorData) { + const UgcModel::LookRules rules; + const UgcBricks::Material plastic{ 200, 0, 0, 255, "shinyPlastic" }, steel{ 150, 150, 150, 255, "shinySteel" }, brushed{ 150, 150, 150, 255, "brushedSteel" }; + EXPECT_EQ(UgcModel::LookOf(21, plastic, rules), UgcModel::eLook::PLASTIC); + EXPECT_EQ(UgcModel::LookOf(5000, steel, rules), UgcModel::eLook::METAL); // a Materials.xml shinySteel + EXPECT_EQ(UgcModel::LookOf(5000, brushed, rules), UgcModel::eLook::BRUSHED); + EXPECT_EQ(UgcModel::LookOf(183, plastic, rules), UgcModel::eLook::METAL); // LU Toolbox's metallic, shinyPlastic in Materials.xml + EXPECT_EQ(UgcModel::LookOf(329, plastic, rules), UgcModel::eLook::GLOW); // LU Toolbox's glow colors + EXPECT_EQ(UgcModel::LookOf(50, plastic, rules), UgcModel::eLook::GLOW); + EXPECT_EQ(UgcModel::LookOf(9016, plastic, rules), UgcModel::eLook::GLOW); + UgcModel::LookRules none; + none.materialTypes.clear(); + none.paletteMetallic = false; + EXPECT_EQ(UgcModel::LookOf(5000, steel, none), UgcModel::eLook::PLASTIC); + EXPECT_EQ(UgcModel::LookOf(150, steel, none), UgcModel::eLook::PLASTIC); + + // Built: opaque vertices get their color's look, transparent bricks none (their glow stays with them) + UgcBricks::BrickLibrary library(MakeRes(), 0); + library.SetMaterials({ { 5000, brushed } }); + std::string error; + const auto model = UgcModel::Build(UgcModel::ParseLxfml(R"( + + + + + )", error), library); + ASSERT_EQ(model.opaque.looks.size(), 24u); + EXPECT_EQ(model.opaque.looks[0], UgcModel::eLook::METAL); + EXPECT_EQ(model.opaque.looks[8], UgcModel::eLook::BRUSHED); + EXPECT_EQ(model.opaque.looks[16], UgcModel::eLook::PLASTIC); + EXPECT_TRUE(model.transparent.looks.empty()); + + // Split by the looks that have groups; the rest stay plastic; nothing to split: the mesh as it is + const auto split = UgcModel::SplitLooks(model.opaque, { false, true, false, false }); + ASSERT_TRUE(split); + EXPECT_EQ((*split)[0].TriangleCount(), 24u); + EXPECT_EQ((*split)[1].TriangleCount(), 12u); + EXPECT_TRUE((*split)[2].Empty()); + EXPECT_FALSE(UgcModel::SplitLooks(model.opaque, { false, false, false, true })); +} + +TEST(UgcShaders, WritesAGroupPerLookWithEveryLevel) { + UgcBricks::BrickLibrary library(MakeRes(), 0); + auto settings = SmallSettings(); + settings.build.colorVariation = 0.0f; + settings.shaders.metal = 88; + settings.shaders.brushed = 89; + settings.shaders.glow = 46; + settings.shaders.glowEmissive = 0.75f; + const auto outcome = UgcJobs::ProcessModel(LOOKS_LXFML, library, settings, 7); + ASSERT_TRUE(outcome.ok) << outcome.error; + const auto nif = *ZCompression::Gunzip(outcome.files.at("model.nif.gz")); + std::string error; + for (const uint32_t level : { 0u, 1u }) { + const auto read = NifFile::Parse(nif, level, error); + ASSERT_TRUE(read) << error; + // No brushed steel colors: no group for them. Every group has both levels. + for (const auto* name : { "S01_Opaque_Model", "S88_Metal_Model", "S46_Glow_Model", "S01_Alpha_Model" }) EXPECT_TRUE(read->nodes.contains(name)) << name; + EXPECT_FALSE(read->nodes.contains("S89_Brushed_Model")); + EXPECT_TRUE(read->nodes.contains(level == 0 ? "LOD_0" : "LOD_2")); + std::map triangles; + for (const auto& mesh : read->meshes) triangles[mesh.material.shaderTag] += mesh.indices.size() / 3; + EXPECT_EQ(triangles[1], 24u); // the plastic brick and the transparent one + EXPECT_EQ(triangles[88], 12u); + EXPECT_EQ(triangles[46], 24u); // 329 and 294 + } + const auto read = NifFile::Parse(nif, 0, error); + const auto noao = NifFile::Parse(*ZCompression::Gunzip(outcome.files.at("model.noao.nif.gz")), 0, error); + ASSERT_TRUE(read && noao); + size_t glowShapes = 0; + for (const auto& mesh : read->meshes) { + if (mesh.material.shaderTag == 46) { + glowShapes++; + // The emissive shader's material, the plain color (as before the lighting bake), opaque + for (const auto value : mesh.material.emissive) EXPECT_FLOAT_EQ(value, 0.75f); + const auto plain = std::find_if(noao->meshes.begin(), noao->meshes.end(), [&](const auto& other) { return other.material.shaderTag == 46 && other.positions == mesh.positions; }); + ASSERT_NE(plain, noao->meshes.end()); + EXPECT_EQ(mesh.colors, plain->colors); + for (size_t i = 3; i < mesh.colors.size(); i += 4) EXPECT_EQ(mesh.colors[i], 255); + } else { + for (const auto value : mesh.material.emissive) EXPECT_EQ(value, 0.0f); + } + } + EXPECT_EQ(glowShapes, 1u); + EXPECT_NE(outcome.stats.find("\"S88_Metal_Model\":12"), std::string::npos) << outcome.stats; +} + +TEST(UgcShaders, IconsDrawGlowUnlitAndMetalShiny) { + // One quad facing the camera, lit from behind: plastic is dark, glow its full color, metal shows a reflection + UgcModel::Model model; + model.opaque.positions = { { -1, -1, 0 }, { 1, -1, 0 }, { -1, 1, 0 }, { 1, 1, 0 } }; + model.opaque.normals.assign(4, { 0, 0, 1 }); + model.opaque.colors.assign(4, { 0.8f, 0.4f, 0.2f, 1.0f }); + model.opaque.indices = { 0, 1, 2, 1, 3, 2 }; + UgcRender::IconOptions options; + options.size = 16; + options.supersample = 1; + options.yawDegrees = 0.0f; + options.pitchDegrees = 0.0f; + options.sunYawDegrees = 180.0f; + options.sunPitchDegrees = 0.0f; + options.shadows = 0.0f; + const auto centre = [&](UgcModel::eLook look) { + auto copy = model; + if (look != UgcModel::eLook::PLASTIC) copy.opaque.looks.assign(4, look); + const auto image = UgcRender::RenderIcon(copy, options); + const size_t at = (8 * 16 + 8) * 4; + return glm::ivec3(image.rgba[at], image.rgba[at + 1], image.rgba[at + 2]); + }; + const auto plastic = centre(UgcModel::eLook::PLASTIC), glow = centre(UgcModel::eLook::GLOW), metal = centre(UgcModel::eLook::METAL); + EXPECT_NEAR(glow.r, 204, 2); + EXPECT_NEAR(glow.g, 102, 2); + EXPECT_NEAR(glow.b, 51, 2); + EXPECT_LT(plastic.r, glow.r); + EXPECT_NE(metal, plastic); + EXPECT_GE(metal.r, metal.g); // tinted by its color + options.glowEmissive = 0.0f; + EXPECT_EQ(centre(UgcModel::eLook::GLOW), plastic); + + // Read back from a .nif by the groups' tags + const UgcModel::Mesh mesh = model.opaque; + const auto nif = UgcFormats::WriteLodNif("SceneNode_Model", { { "S46_Glow_Model", false, { { 0.0f, 100.0f, "LOD_0", { &mesh } } }, 1.0f } }); + std::string error; + const auto read = NifFile::Parse(nif, 0, error); + ASSERT_TRUE(read) << error; + EXPECT_EQ(UgcModel::FromNif(*read, UgcJobs::Shaders{}.TagLooks()).opaque.looks, std::vector(4, UgcModel::eLook::GLOW)); + EXPECT_TRUE(UgcModel::FromNif(*read).opaque.looks.empty()); +}