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<id>_Metal_Model, S<id>_Brushed_Model and
S<id>_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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 09:55:00 -05:00
parent b65220b446
commit c39b7ee935
16 changed files with 560 additions and 45 deletions

View File

@@ -450,6 +450,14 @@ namespace {
c.Add(Float(UGC, "lod_cull", "Drawn up to", "", "10000", 0, 1000000)); c.Add(Float(UGC, "lod_cull", "Drawn up to", "", "10000", 0, 1000000));
c.Add(Text(UGC, "shader_opaque", "Opaque shader", "S<shader>_Opaque_Model.", "01")); c.Add(Text(UGC, "shader_opaque", "Opaque shader", "S<shader>_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)); 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<id>_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<id>_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<id>_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, "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(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")); c.Add(Unit(Int(UGC, "optimize_resolution", "Detail of the visibility renders", "", "1024", 64, 4096), "pixels"));

View File

@@ -69,7 +69,7 @@ namespace UgcBricks {
const auto channel = [element](const char* name, uint32_t fallback) { const auto channel = [element](const char* name, uint32_t fallback) {
return static_cast<uint8_t>(std::min<uint32_t>(element->UnsignedAttribute(name, fallback), 255)); return static_cast<uint8_t>(std::min<uint32_t>(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; return materials;
} }

View File

@@ -26,19 +26,20 @@ namespace UgcBricks {
std::vector<uint32_t> indices; std::vector<uint32_t> 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 { struct Material {
uint8_t r{ 160 }; uint8_t r{ 160 };
uint8_t g{ 160 }; uint8_t g{ 160 };
uint8_t b{ 160 }; uint8_t b{ 160 };
uint8_t a{ 255 }; uint8_t a{ 255 };
std::string type;
bool Transparent() const { return a < 255; } bool Transparent() const { return a < 255; }
}; };
// A .g file ("10GB" magic, counts, positions, normals, texture coordinates for decorated parts, indices) // A .g file ("10GB" magic, counts, positions, normals, texture coordinates for decorated parts, indices)
std::optional<Geometry> ParseGeometry(std::string_view data); std::optional<Geometry> ParseGeometry(std::string_view data);
// Materials.xml: MatID -> color // Materials.xml: MatID -> color and type
std::map<uint32_t, Material> ParseMaterials(std::string_view xml); std::map<uint32_t, Material> 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 // A file from a zip archive (stored or deflated), matched without regard to case; nullopt when it isn't there

View File

@@ -5,6 +5,7 @@
#include <cmath> #include <cmath>
#include <cstring> #include <cstring>
#include <limits> #include <limits>
#include <map>
#include "MD5.h" #include "MD5.h"
#include "ZCompression.h" #include "ZCompression.h"
@@ -204,15 +205,7 @@ namespace {
class SharedProperties { class SharedProperties {
public: public:
explicit SharedProperties(NifBuilder& nif) : m_Nif(nif) { explicit SharedProperties(NifBuilder& nif) : m_Nif(nif) {
Writer material; m_Material = nif.Add("NiMaterialProperty", Material(0.0f));
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()));
Writer vertexColor; Writer vertexColor;
WriteNet(vertexColor, -1); WriteNet(vertexColor, -1);
@@ -220,8 +213,22 @@ namespace {
m_VertexColor = nif.Add("NiVertexColorProperty", std::move(vertexColor.Data())); m_VertexColor = nif.Add("NiVertexColorProperty", std::move(vertexColor.Data()));
} }
// An NiTriShape of `mesh` (-1 when it is empty or too big for the format) // A white NiMaterialProperty with this emissive color (grey)
int32_t Shape(const std::string& name, const UgcModel::Mesh* mesh, bool transparent) { 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; 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 // 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 // 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())); m_Specular = m_Nif.Add("NiSpecularProperty", std::move(specular.Data()));
} }
(void)transparent; (void)transparent;
std::vector<int32_t> 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<int32_t> properties{ material, m_Alpha, m_Specular, m_VertexColor };
const auto shapeBlock = m_Nif.Reserve("NiTriShape"); const auto shapeBlock = m_Nif.Reserve("NiTriShape");
const auto dataBlock = m_Nif.Add("NiTriShapeData", TriShapeData(*mesh)); const auto dataBlock = m_Nif.Add("NiTriShapeData", TriShapeData(*mesh));
Writer tri; Writer tri;
@@ -257,6 +270,7 @@ namespace {
int32_t m_VertexColor{ -1 }; int32_t m_VertexColor{ -1 };
int32_t m_Alpha{ -1 }; int32_t m_Alpha{ -1 };
int32_t m_Specular{ -1 }; int32_t m_Specular{ -1 };
std::map<float, int32_t> m_Emissive; // emissive color -> its material
}; };
} }
@@ -290,7 +304,7 @@ namespace UgcFormats {
const auto level = nif.Reserve("NiNode"); const auto level = nif.Reserve("NiNode");
std::vector<int32_t> shapes; std::vector<int32_t> shapes;
for (const auto* piece : lod.pieces) { 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); if (block >= 0) shapes.push_back(block);
} }
nif.Fill(level, NodeData(nif.String(lod.name), shapes)); nif.Fill(level, NodeData(nif.String(lod.name), shapes));

View File

@@ -40,12 +40,16 @@ namespace UgcFormats {
std::string name; // S01_Opaque_Model: the NiLODNode and its shapes std::string name; // S01_Opaque_Model: the NiLODNode and its shapes
bool transparent{}; bool transparent{};
std::vector<NifLod> lods; std::vector<NifLod> 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 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 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<NifLodGroup>& groups); std::string WriteLodNif(const std::string& rootName, const std::vector<NifLodGroup>& groups);

View File

@@ -65,15 +65,51 @@ namespace UgcJobs {
return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count(); return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
} }
std::string ShapeName(const std::string& shader, bool transparent) { // An opaque mesh's pieces by look ([eLook]: UgcModel::Divide's pieces), the looks without a shader of their own
return ("S" + (transparent ? std::string("01") : shader) + (transparent ? "_Alpha_" : "_Opaque_") + "Model").substr(0, 60); // with the plastic ones
using LookPieces = std::array<std::vector<UgcModel::Mesh>, UgcModel::LOOK_COUNT>;
LookPieces DivideByLook(const UgcModel::Mesh& mesh, const std::array<bool, UgcModel::LOOK_COUNT>& 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<int32_t, UgcModel::eLook> Shaders::TagLooks() const {
std::map<int32_t, UgcModel::eLook> 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<int32_t>(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<int32_t, UgcModel::eLook>& tagLooks) {
const auto readBack = NifFile::Parse(nif, 0, error); const auto readBack = NifFile::Parse(nif, 0, error);
if (!readBack) return false; if (!readBack) return false;
AddIcon(files, UgcModel::FromNif(*readBack), options); AddIcon(files, UgcModel::FromNif(*readBack, tagLooks), options);
return true; 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 // Every LOD made like LU Toolbox makes each LOD collection: colored, hidden faces removed, lighting baked, divided
std::vector<UgcModel::Model> models; std::vector<UgcModel::Model> models;
std::vector<std::vector<UgcModel::Mesh>> opaquePieces, transparentPieces; std::vector<LookPieces> opaquePieces;
std::vector<std::vector<UgcModel::Mesh>> transparentPieces;
// The looks with a shader of their own (UgcJobs::Shaders), each an NiLODNode apart from the plastic
std::array<bool, UgcModel::LOOK_COUNT> separate{};
for (size_t look = 1; look < UgcModel::LOOK_COUNT; look++) separate[look] = settings.shaders.TagOf(static_cast<UgcModel::eLook>(look)) != 0;
const bool glowApart = separate[static_cast<size_t>(UgcModel::eLook::GLOW)];
UgcModel::Model preview; // LOD 0 before the lighting bake, for the dashboard UgcModel::Model preview; // LOD 0 before the lighting bake, for the dashboard
for (size_t i = 0; i < lods.size(); i++) { for (size_t i = 0; i < lods.size(); i++) {
auto options = settings.build; 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"; outcome.note += "removed " + std::to_string(optimized.trianglesRemoved) + " of " + std::to_string(optimized.trianglesBefore) + " triangles";
} }
if (i == 0) preview = model; 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<glm::vec4> plainColors;
if (glowApart && !model.opaque.looks.empty()) plainColors = model.opaque.colors;
step = std::chrono::steady_clock::now(); step = std::chrono::steady_clock::now();
UgcRender::BakeAo(model, settings.ao); UgcRender::BakeAo(model, settings.ao);
aoMs += Since(step); 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["opaqueAfter"] = model.opaque.TriangleCount();
entry["vertices"] = model.opaque.positions.size() + model.transparent.positions.size(); 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)); 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<UgcModel::eLook>(look), false)] = triangles;
}
if (!model.transparent.Empty()) byGroup[ShapeName(settings, UgcModel::eLook::PLASTIC, true)] = model.transparent.TriangleCount();
}
lodStats.push_back(entry); lodStats.push_back(entry);
} }
outcome.aoBaked = settings.ao.enabled; outcome.aoBaked = settings.ao.enabled;
// An NiLODNode for the opaque bricks and one for the transparent ones, as LU Toolbox names them // An NiLODNode for the opaque bricks and one for the transparent ones, as LU Toolbox names them, and one for
const auto groups = [&](size_t levels, const std::vector<std::vector<UgcModel::Mesh>>& opaque, const std::vector<std::vector<UgcModel::Mesh>>& transparent) { // 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<LookPieces>& opaque, const std::vector<std::vector<UgcModel::Mesh>>& transparent) {
std::vector<UgcFormats::NifLodGroup> out; std::vector<UgcFormats::NifLodGroup> out;
for (const bool isTransparent : { false, true }) { for (size_t kind = 0; kind <= UgcModel::LOOK_COUNT; kind++) {
UgcFormats::NifLodGroup group{ ShapeName(settings.shaderOpaque, isTransparent), isTransparent, {} }; const bool isTransparent = kind == UgcModel::LOOK_COUNT;
const auto look = isTransparent ? UgcModel::eLook::PLASTIC : static_cast<UgcModel::eLook>(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; bool any = false;
for (size_t i = 0; i < levels; i++) { for (size_t i = 0; i < levels; i++) {
UgcFormats::NifLod lod{ ranges[i].first, ranges[i].second, "LOD_" + std::to_string(lods[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(); any = any || !lod.pieces.empty();
group.lods.push_back(std::move(lod)); group.lods.push_back(std::move(lod));
} }
@@ -177,7 +241,8 @@ namespace UgcJobs {
// LXFML is served from the database. // LXFML is served from the database.
AddDownload(outcome.files, "model.nif", nif); AddDownload(outcome.files, "model.nif", nif);
{ {
const std::vector<std::vector<UgcModel::Mesh>> opaque{ UgcModel::Divide(preview.opaque) }, transparent{ transparentPieces[0] }; const std::vector<LookPieces> opaque{ DivideByLook(preview.opaque, separate) };
const std::vector<std::vector<UgcModel::Mesh>> transparent{ transparentPieces[0] };
outcome.files["model.noao.nif.gz"] = ZCompression::Gzip(UgcFormats::WriteLodNif("SceneNode_Model", groups(1, opaque, transparent))); 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; auto iconOptions = settings.icon;
UgcIconParams::Apply(iconOptions, iconValues); UgcIconParams::Apply(iconOptions, iconValues);
std::string nifError; 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; outcome.error = "the .nif made can't be read back for the icon: " + nifError;
return outcome; return outcome;
} }

View File

@@ -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 * 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. * 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<int32_t, UgcModel::eLook> TagLooks() const;
};
struct Settings { struct Settings {
UgcModel::BuildOptions build; // palette, color variation, transparent opacity UgcModel::BuildOptions build; // palette, color variation, transparent opacity
std::vector<uint32_t> lods{ 0, 2 }; // brickprimitives levels made (LU Toolbox imports LOD 0 and 2; the client has no 3) std::vector<uint32_t> lods{ 0, 2 }; // brickprimitives levels made (LU Toolbox imports LOD 0 and 2; the client has no 3)
UgcModel::LodDistances lodDistances; UgcModel::LodDistances lodDistances;
std::string shaderOpaque{ "01" }; // S<shader>_Opaque_...; transparent shapes are always S01 std::string shaderOpaque{ "01" }; // S<shader>_Opaque_...; transparent shapes are always S01
bool combineTransparent{ false }; // one shape for all transparent bricks, else one per brick (Combine Transparent) 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::OptimizeOptions optimize; // hidden surface removal
UgcRender::AoOptions ao; // Bake Lighting (AO Only) UgcRender::AoOptions ao; // Bake Lighting (AO Only)
UgcRender::IconOptions icon; // from the icon_* settings (UgcIconParams); presets and overrides go over it 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, Outcome ProcessModel(const std::string& blob, UgcBricks::BrickLibrary& library, const Settings& settings, uint64_t seed = 0,
const UgcIconParams::Values& iconValues = {}); 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 // A model's icon files (icon.png, icon.dds download) drawn from its .nif (LOD 0), its metal and glow groups by
// can't be read // `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); bool IconFromNif(const std::string& nif, const UgcRender::IconOptions& options, UgcStorage::Files& files, std::string& error,
const std::map<int32_t, UgcModel::eLook>& 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) // How many bricks (parts) an LXFML has, counted cheaply (for the memory estimate before a job starts)
size_t CountParts(std::string_view lxfml); size_t CountParts(std::string_view lxfml);

View File

@@ -159,6 +159,11 @@ namespace UgcModel {
if (other.glow.empty()) glow.resize(positions.size(), glm::vec3(0.0f)); if (other.glow.empty()) glow.resize(positions.size(), glm::vec3(0.0f));
else glow.insert(glow.end(), other.glow.begin(), other.glow.end()); 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()); indices.reserve(indices.size() + other.indices.size());
for (const auto index : other.indices) indices.push_back(base + index); for (const auto index : other.indices) indices.push_back(base + index);
} }
@@ -185,11 +190,39 @@ namespace UgcModel {
return any; 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<std::array<Mesh, LOOK_COUNT>> SplitLooks(const Mesh& mesh, const std::array<bool, LOOK_COUNT>& 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<size_t>(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<Mesh, LOOK_COUNT> out;
for (size_t look = 0; look < LOOK_COUNT; look++) {
std::vector<bool> keep(mesh.TriangleCount());
bool some = false;
for (size_t t = 0; t < keep.size(); t++) some = (keep[t] = static_cast<size_t>(lookOf(t)) == look) || some;
if (!some) continue;
out[look] = mesh;
KeepTriangles(out[look], keep);
}
return out;
}
Model Build(const std::vector<Part>& parts, UgcBricks::BrickLibrary& library, const BuildOptions& options) { Model Build(const std::vector<Part>& parts, UgcBricks::BrickLibrary& library, const BuildOptions& options) {
Model model; Model model;
std::set<uint32_t> missing; std::set<uint32_t> missing;
const bool luToolbox = options.palette == ePalette::LU_TOOLBOX; 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++) { for (uint32_t brick = 0; brick < parts.size(); brick++) {
const auto& part = parts[brick]; const auto& part = parts[brick];
const auto design = library.GetDesign(part.designId, options.lod); const auto design = library.GetDesign(part.designId, options.lod);
@@ -249,6 +282,8 @@ namespace UgcModel {
} }
const glm::vec4 color(UgcPalette::LinearToSrgb(linear), alpha); const glm::vec4 color(UgcPalette::LinearToSrgb(linear), alpha);
anyGlow = anyGlow || glow != glm::vec3(0.0f); 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<uint32_t>(mesh.positions.size()); const auto base = static_cast<uint32_t>(mesh.positions.size());
const size_t vertexCount = geometry.positions.size() / 3; const size_t vertexCount = geometry.positions.size() / 3;
for (size_t v = 0; v < vertexCount; v++) { 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.positions.push_back(glm::vec3(part.transform * glm::vec4(position, 1.0f)));
mesh.normals.push_back(normal); mesh.normals.push_back(normal);
mesh.colors.push_back(color); 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); for (const auto i : geometry.indices) mesh.indices.push_back(base + i);
} }
} }
if (!anyGlow) model.opaque.glow.clear(); if (!anyGlow) model.opaque.glow.clear();
else model.opaque.glow.resize(model.opaque.positions.size(), glm::vec3(0.0f)); 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()); model.missingDesigns.assign(missing.begin(), missing.end());
return model; return model;
} }
@@ -303,7 +342,7 @@ namespace UgcModel {
return ranges; return ranges;
} }
Model FromNif(const NifFile::Model& nif) { Model FromNif(const NifFile::Model& nif, const std::map<int32_t, eLook>& tagLooks) {
Model model; Model model;
for (const auto& source : nif.meshes) { for (const auto& source : nif.meshes) {
Mesh mesh; Mesh mesh;
@@ -334,6 +373,9 @@ namespace UgcModel {
// Blending only shows where something is see-through (the game's brick models blend every shape) // 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; 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; 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); (seeThrough ? model.transparent : model.opaque).Append(mesh);
} }
return model; return model;
@@ -355,6 +397,7 @@ namespace UgcModel {
if (source < mesh.normals.size()) piece.normals.push_back(mesh.normals[source]); 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.colors.size()) piece.colors.push_back(mesh.colors[source]);
if (source < mesh.glow.size()) piece.glow.push_back(mesh.glow[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); 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.normals.size()) kept.normals.push_back(mesh.normals[source]);
if (source < mesh.colors.size()) kept.colors.push_back(mesh.colors[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.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]); 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.normals.size()) current.normals.push_back(mesh.normals[source]);
if (source < mesh.colors.size()) current.colors.push_back(mesh.colors[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.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); 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.normals.size()) half.normals.push_back(mesh.normals[source]);
if (source < mesh.colors.size()) half.colors.push_back(mesh.colors[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.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]); half.indices.push_back(remap[source]);
} }

View File

@@ -1,6 +1,9 @@
#pragma once #pragma once
#include <array>
#include <cstdint> #include <cstdint>
#include <map>
#include <optional>
#include <string> #include <string>
#include <string_view> #include <string_view>
#include <vector> #include <vector>
@@ -32,11 +35,19 @@ namespace UgcModel {
// Whether an LXFML reads but has no bricks at all (nothing to make; not a failure) // Whether an LXFML reads but has no bricks at all (nothing to make; not a failure)
bool HasNoBricks(std::string_view lxfml); 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 { struct Mesh {
std::vector<glm::vec3> positions; std::vector<glm::vec3> positions;
std::vector<glm::vec3> normals; std::vector<glm::vec3> normals;
std::vector<glm::vec4> colors; // sRGB, 0..1, alpha is opacity std::vector<glm::vec4> colors; // sRGB, 0..1, alpha is opacity
std::vector<glm::vec3> glow; // linear glow color per vertex (LU Toolbox's "Glow" layer); empty when nothing glows std::vector<glm::vec3> glow; // linear glow color per vertex (LU Toolbox's "Glow" layer); empty when nothing glows
std::vector<eLook> looks; // per vertex; empty when everything is plastic
std::vector<uint32_t> indices; std::vector<uint32_t> indices;
size_t TriangleCount() const { return indices.size() / 3; } size_t TriangleCount() const { return indices.size() / 3; }
@@ -62,6 +73,19 @@ namespace UgcModel {
BRICKDB, // the brick database's Materials.xml 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<std::string, eLook> 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 { struct BuildOptions {
ePalette palette{ ePalette::LU_TOOLBOX }; ePalette palette{ ePalette::LU_TOOLBOX };
float colorVariation{ 5.0f }; // percent, 0: none (LU Toolbox: Apply Color Variation, 5%) 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) float transparentOpacity{ 58.82f }; // percent, transparent bricks' vertex alpha (LU Toolbox palette only)
bool icon{}; // the icon renderer's color corrections bool icon{}; // the icon renderer's color corrections
uint32_t lod{}; // brickprimitives level uint32_t lod{}; // brickprimitives level
LookRules looks; // which colors are metal and glow (Mesh::looks)
}; };
/** /**
@@ -100,8 +125,15 @@ namespace UgcModel {
*/ */
std::vector<Mesh> Divide(const Mesh& mesh, size_t maxVertices = 65535, size_t maxTriangles = 65535); std::vector<Mesh> 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) // A client .nif's meshes as one model (vertex colors times material color; transparent when blended). `tagLooks`:
Model FromNif(const NifFile::Model& nif); // 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<int32_t, eLook>& 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<std::array<Mesh, LOOK_COUNT>> SplitLooks(const Mesh& mesh, const std::array<bool, LOOK_COUNT>& separate);
// The mesh cut into pieces at these index offsets (each piece's triangles start at one), e.g. one per brick // The mesh cut into pieces at these index offsets (each piece's triangles start at one), e.g. one per brick
std::vector<Mesh> SplitAt(const Mesh& mesh, const std::vector<size_t>& starts); std::vector<Mesh> SplitAt(const Mesh& mesh, const std::vector<size_t>& starts);

View File

@@ -209,7 +209,7 @@ void UgcProcessor::Worker() {
const auto nif = m_Storage.ReadNif(Kind::MODEL, job.id, "model.nif"); const auto nif = m_Storage.ReadNif(Kind::MODEL, job.id, "model.nif");
auto options = settings.icon; auto options = settings.icon;
UgcIconParams::Apply(options, job.iconValues); 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 (!nif) outcome.error = "the model has no stored .nif yet";
} }
if (outcome.ok && outcome.files.contains("assembly.nif")) { 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"); const auto nif = m_Storage.ReadNif(Kind::MODEL, job.id, "model.nif");
auto options = settings.icon; auto options = settings.icon;
UgcIconParams::Apply(options, job.iconValues); 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"; if (!nif) done.outcome.error = "no stored .nif";
} else { } else {
done.outcome = job.kind == Kind::MODEL done.outcome = job.kind == Kind::MODEL

View File

@@ -483,8 +483,28 @@ namespace UgcRender {
// The sun's highlight (Blinn-Phong), white, on top of the color // 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 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); const float exposure = std::max(options.exposure, 0.0f);
return glm::vec4((ToLinear(base.r) * lighting + highlight) * exposure, (ToLinear(base.g) * lighting + highlight) * exposure, const auto look = isOpaque && mesh.looks.size() == mesh.positions.size() ? mesh.looks[i0] : UgcModel::eLook::PLASTIC;
(ToLinear(base.b) * lighting + highlight) * exposure, std::clamp(base.a, 0.0f, 1.0f)); 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 // Opaque first, with the depth buffer

View File

@@ -55,10 +55,15 @@ namespace UgcRender {
float contrast{ 1.0f }; // around the middle grey of the sRGB result 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 float shadows{ 1.0f }; // how much the sun's shadows darken, 0 to 1
AoOptions ao{ false, 5.0f, 32, 1.0f, 0.0f }; 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 // 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. // 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<float>* opaqueAo = nullptr); Image RenderIcon(const UgcModel::Model& model, const IconOptions& options, const std::vector<float>* opaqueAo = nullptr);
struct OptimizeOptions { struct OptimizeOptions {

View File

@@ -88,6 +88,24 @@ namespace {
settings.lodDistances.cull = Setting<float>("lod_cull", 10000.0f); settings.lodDistances.cull = Setting<float>("lod_cull", 10000.0f);
if (!Game::config->GetValue("shader_opaque").empty()) settings.shaderOpaque = Game::config->GetValue("shader_opaque"); if (!Game::config->GetValue("shader_opaque").empty()) settings.shaderOpaque = Game::config->GetValue("shader_opaque");
settings.combineTransparent = Setting<int32_t>("combine_transparent", 0) != 0; settings.combineTransparent = Setting<int32_t>("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<uint32_t>("shader_metal", 0), 9999u);
settings.shaders.brushed = std::min(Setting<uint32_t>("shader_brushed", 0), 9999u);
settings.shaders.glow = std::min(Setting<uint32_t>("shader_glow", 0), 9999u);
settings.shaders.glowEmissive = std::clamp(Setting<float>("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<int32_t>("remove_hidden_faces", 1) != 0; settings.optimize.removeHidden = Setting<int32_t>("remove_hidden_faces", 1) != 0;
settings.optimize.groundPlane = Setting<int32_t>("hsr_ground_plane", 0) != 0; settings.optimize.groundPlane = Setting<int32_t>("hsr_ground_plane", 0) != 0;
settings.optimize.resolution = Setting<int32_t>("optimize_resolution", 1024); settings.optimize.resolution = Setting<int32_t>("optimize_resolution", 1024);

View File

@@ -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 | | 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`) | | 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) | | 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 | | 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 | | 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 | | 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 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 | | 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<id>_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<id>_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<id>_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_<n>` 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`): 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 1. Each module LOT's `ModuleComponent` (component type 28) gives its part code and build

View File

@@ -64,6 +64,24 @@ lod_cull=10000
shader_opaque=01 shader_opaque=01
combine_transparent=0 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<id>_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; # 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) # hsr_ground_plane: 1 also removes what can only be seen from below the model)
remove_hidden_faces=1 remove_hidden_faces=1

View File

@@ -102,9 +102,11 @@ TEST(UgcBricks, ParsesGeometryAndRejectsBadData) {
} }
TEST(UgcBricks, ParsesMaterials) { TEST(UgcBricks, ParsesMaterials) {
const auto materials = UgcBricks::ParseMaterials(R"(<Materials><Material MatID="21" Red="222" Green="0" Blue="13" Alpha="255"/><Material MatID="40" Red="238" Green="238" Blue="238" Alpha="150"/></Materials>)"); const auto materials = UgcBricks::ParseMaterials(R"(<Materials><Material MatID="21" Red="222" Green="0" Blue="13" Alpha="255"/><Material MatID="40" Red="238" Green="238" Blue="238" Alpha="150" MaterialType="shinySteel"/></Materials>)");
ASSERT_EQ(materials.size(), 2u); ASSERT_EQ(materials.size(), 2u);
EXPECT_EQ(materials.at(21).r, 222); 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_FALSE(materials.at(21).Transparent());
EXPECT_TRUE(materials.at(40).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"(<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<LXFML versionMajor="5" versionMinor="0"><Bricks>
<Brick><Part designID="3001" materials="21"><Bone transformation="1,0,0,0,1,0,0,0,1,0,0,0"/></Part></Brick>
<Brick><Part designID="3001" materials="150"><Bone transformation="1,0,0,0,1,0,0,0,1,3,0,0"/></Part></Brick>
<Brick><Part designID="3001" materials="329"><Bone transformation="1,0,0,0,1,0,0,0,1,6,0,0"/></Part></Brick>
<Brick><Part designID="3001" materials="294"><Bone transformation="1,0,0,0,1,0,0,0,1,9,0,0"/></Part></Brick>
<Brick><Part designID="3001" materials="40"><Bone transformation="1,0,0,0,1,0,0,0,1,12,0,0"/></Part></Brick>
</Bricks></LXFML>)";
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"(<LXFML versionMajor="5"><Bricks>
<Brick><Part designID="3001" materials="150"><Bone transformation="1,0,0,0,1,0,0,0,1,0,0,0"/></Part></Brick>
<Brick><Part designID="3001" materials="5000"><Bone transformation="1,0,0,0,1,0,0,0,1,3,0,0"/></Part></Brick>
<Brick><Part designID="3001" materials="21"><Bone transformation="1,0,0,0,1,0,0,0,1,6,0,0"/></Part></Brick>
<Brick><Part designID="3001" materials="40"><Bone transformation="1,0,0,0,1,0,0,0,1,9,0,0"/></Part></Brick>
</Bricks></LXFML>)", 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<int32_t, size_t> 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<UgcModel::eLook>(4, UgcModel::eLook::GLOW));
EXPECT_TRUE(UgcModel::FromNif(*read).opaque.looks.empty());
}