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

@@ -69,7 +69,7 @@ namespace UgcBricks {
const auto channel = [element](const char* name, uint32_t fallback) {
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;
}

View File

@@ -26,19 +26,20 @@ namespace UgcBricks {
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 {
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<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);
// 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 <cstring>
#include <limits>
#include <map>
#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<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 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<float, int32_t> m_Emissive; // emissive color -> its material
};
}
@@ -290,7 +304,7 @@ namespace UgcFormats {
const auto level = nif.Reserve("NiNode");
std::vector<int32_t> 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));

View File

@@ -40,12 +40,16 @@ namespace UgcFormats {
std::string name; // S01_Opaque_Model: the NiLODNode and its shapes
bool transparent{};
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 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);

View File

@@ -65,15 +65,51 @@ namespace UgcJobs {
return std::chrono::duration<double, std::milli>(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<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);
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<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
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<glm::vec4> 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<UgcModel::eLook>(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<std::vector<UgcModel::Mesh>>& opaque, const std::vector<std::vector<UgcModel::Mesh>>& 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<LookPieces>& opaque, const std::vector<std::vector<UgcModel::Mesh>>& transparent) {
std::vector<UgcFormats::NifLodGroup> 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<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;
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<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)));
}
@@ -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;
}

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
* 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 {
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)
UgcModel::LodDistances lodDistances;
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)
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<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)
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));
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<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 model;
std::set<uint32_t> 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<uint32_t>(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<int32_t, eLook>& 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]);
}

View File

@@ -1,6 +1,9 @@
#pragma once
#include <array>
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
@@ -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<glm::vec3> positions;
std::vector<glm::vec3> normals;
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<eLook> looks; // per vertex; empty when everything is plastic
std::vector<uint32_t> 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<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 {
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<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)
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<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
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");
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

View File

@@ -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

View File

@@ -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<float>* opaqueAo = nullptr);
struct OptimizeOptions {

View File

@@ -88,6 +88,24 @@ namespace {
settings.lodDistances.cull = Setting<float>("lod_cull", 10000.0f);
if (!Game::config->GetValue("shader_opaque").empty()) settings.shaderOpaque = Game::config->GetValue("shader_opaque");
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.groundPlane = Setting<int32_t>("hsr_ground_plane", 0) != 0;
settings.optimize.resolution = Setting<int32_t>("optimize_resolution", 1024);