fix(ugc): transparent bricks drawn see-through in mixed models; dUgcServer in folders by role

- The client puts a shape in its sorted, blended pass only when its
  NiMaterialProperty alpha is under 0.99999 (ShaderCommon::GetAlphaFlags
  0x0109f5a0; the NiAlphaProperty blend flag isn't read); at 1.0 it's drawn
  solid with blending off. Transparent (and transparent glitter) shapes now
  get a material with alpha 0.9999, as the S01_Alpha shapes of the game's own
  brick models (res/BrickModels/ndmade) do; opaque shapes keep 1.0. Models
  with a transparent brick change; the others are byte for byte the same.
- dUgcServer's files move into Bricks/, Model/, Render/, Formats/ and
  Processing/ (the CMakeLists says what each holds); includes are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 19:51:13 -05:00
parent 2277cbd7e2
commit 7c32779e89
32 changed files with 70 additions and 25 deletions

View File

@@ -0,0 +1,73 @@
#include "UgcGlitter.h"
#include <algorithm>
#include <cmath>
namespace UgcGlitter {
std::vector<uint8_t> FleckAlpha(uint32_t flecks) {
constexpr int N = TEXTURE_SIZE;
std::vector<float> alpha(static_cast<size_t>(N) * N, 0.0f);
// SplitMix64 from a fixed seed: the same texture on every platform
uint64_t state = 0x6C69747465720000ull;
const auto next = [&state] {
uint64_t z = (state += 0x9E3779B97F4A7C15ull);
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ull;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBull;
return static_cast<float>((z ^ (z >> 31)) >> 40) / static_cast<float>(1ull << 24);
};
for (uint32_t i = 0; i < flecks; i++) {
const float cx = next() * N, cy = next() * N;
const float radius = 1.2f + next() * 1.0f;
const float peak = 0.65f + next() * 0.35f;
const int reach = static_cast<int>(std::ceil(radius));
for (int dy = -reach; dy <= reach; dy++) {
for (int dx = -reach; dx <= reach; dx++) {
const int x = static_cast<int>(std::floor(cx)) + dx, y = static_cast<int>(std::floor(cy)) + dy;
const float ddx = x + 0.5f - cx, ddy = y + 0.5f - cy;
const float d = std::sqrt(ddx * ddx + ddy * ddy) / radius;
if (d >= 1.0f) continue;
auto& value = alpha[static_cast<size_t>(((y % N) + N) % N) * N + ((x % N) + N) % N];
value = std::max(value, peak * (1.0f - d * d));
}
}
}
std::vector<uint8_t> out(alpha.size());
for (size_t i = 0; i < alpha.size(); i++) out[i] = static_cast<uint8_t>(std::lround(std::clamp(alpha[i], 0.0f, 1.0f) * 255.0f));
return out;
}
std::vector<std::vector<uint8_t>> Mipmaps(const std::vector<uint8_t>& alpha) {
std::vector<std::vector<uint8_t>> levels{ alpha };
for (int size = TEXTURE_SIZE / 2; size >= 1; size /= 2) {
const auto& above = levels.back();
const int from = size * 2;
std::vector<uint8_t> level(static_cast<size_t>(size) * size);
for (int y = 0; y < size; y++) {
for (int x = 0; x < size; x++) {
const auto at = [&](int dx, int dy) { return static_cast<int>(above[static_cast<size_t>(y * 2 + dy) * from + x * 2 + dx]); };
level[static_cast<size_t>(y) * size + x] = static_cast<uint8_t>((at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1) + 2) / 4);
}
}
levels.push_back(std::move(level));
}
return levels;
}
glm::vec2 Uv(const glm::vec3& position, const glm::vec3& normal, float tile) {
const auto a = glm::abs(normal);
const float scale = 1.0f / std::max(tile, 1e-3f);
if (a.x >= a.y && a.x >= a.z) return glm::vec2(position.z, position.y) * scale;
if (a.y >= a.z) return glm::vec2(position.x, position.z) * scale;
return glm::vec2(position.x, position.y) * scale;
}
float Sample(const std::vector<uint8_t>& alpha, const glm::vec2& uv) {
constexpr int N = TEXTURE_SIZE;
if (alpha.size() != static_cast<size_t>(N) * N) return 0.0f;
const float x = (uv.x - std::floor(uv.x)) * N - 0.5f, y = (uv.y - std::floor(uv.y)) * N - 0.5f;
const int x0 = static_cast<int>(std::floor(x)), y0 = static_cast<int>(std::floor(y));
const float fx = x - x0, fy = y - y0;
const auto at = [&](int px, int py) { return alpha[static_cast<size_t>(((py % N) + N) % N) * N + ((px % N) + N) % N] / 255.0f; };
return (at(x0, y0) * (1 - fx) + at(x0 + 1, y0) * fx) * (1 - fy) + (at(x0, y0 + 1) * (1 - fx) + at(x0 + 1, y0 + 1) * fx) * fy;
}
}

View File

@@ -0,0 +1,41 @@
#pragma once
#include <cstdint>
#include <vector>
#include <glm/glm.hpp>
/**
* The glitter the UGC server gives glitter colors (docs/UgcServer.md, "Metal and glow"): a tileable texture of white
* flecks (its alpha) laid over the brick's color by the client's LEGO-AnimUV shader (lerp(vertex color, texture,
* texture alpha), then the LEGO lighting), on UVs projected from the model's own coordinates so every brick gets the
* same density, drifting as the texture transform's translation loops. Pure.
*/
namespace UgcGlitter {
// The texture's side in pixels (a power of two, mipmapped down to 1)
constexpr int TEXTURE_SIZE = 128;
struct Params {
float tile{ 1.6f }; // glitter_size: the texture's side in model units (LDD units: a stud is 0.8)
uint32_t flecks{ 50 }; // glitter_density: flecks in one tile
float speed{ 1.0f }; // glitter_speed: 1 moves the flecks a tile in U in 7 s and in V in 11 s; 0 keeps them still
// Seconds the texture's translation takes to go one tile in U and in V (0: no animation)
float PeriodU() const { return speed > 0.0f ? 7.0f / speed : 0.0f; }
float PeriodV() const { return speed > 0.0f ? 11.0f / speed : 0.0f; }
bool operator==(const Params&) const = default;
};
// The texture's alpha (TEXTURE_SIZE squared, rows top to bottom): `flecks` soft dots at the same places every time,
// wrapping around the edges so the texture tiles. Its color is white.
std::vector<uint8_t> FleckAlpha(uint32_t flecks);
// The texture's mipmaps' alpha, from TEXTURE_SIZE down to 1 (each the mean of 2x2 of the one before)
std::vector<std::vector<uint8_t>> Mipmaps(const std::vector<uint8_t>& alpha);
// A vertex's UV: its position on the axis plane its normal faces most, in tiles
glm::vec2 Uv(const glm::vec3& position, const glm::vec3& normal, float tile);
// The texture's alpha (0..1) at `uv` (wrapping, bilinear)
float Sample(const std::vector<uint8_t>& alpha, const glm::vec2& uv);
}

View File

@@ -0,0 +1,530 @@
#include "UgcModel.h"
#include <algorithm>
#include <cmath>
#include <functional>
#include <set>
#include <sstream>
#include <unordered_map>
#include <glm/gtc/matrix_transform.hpp>
#include "NifFile.h"
#include "UgcPalette.h"
#include "tinyxml2.h"
namespace {
std::vector<float> ParseFloats(const char* text) {
std::vector<float> values;
if (!text) return values;
std::stringstream stream(text);
std::string item;
while (std::getline(stream, item, ',')) {
try {
values.push_back(std::stof(item));
} catch (...) {
values.push_back(0.0f);
}
}
return values;
}
std::vector<uint32_t> ParseMaterials(const char* text) {
std::vector<uint32_t> values;
if (!text) return values;
std::stringstream stream(text);
std::string item;
while (std::getline(stream, item, ',')) {
try {
values.push_back(static_cast<uint32_t>(std::stoul(item)));
} catch (...) {
values.push_back(0);
}
}
// Material 0 means "the same as the part's first"
for (auto& value : values) {
if (value == 0 && !values.empty()) value = values[0];
}
return values;
}
// LXFML 5 bone: a row-major 3x3 rotation followed by a translation
glm::mat4 BoneMatrix(const std::vector<float>& t) {
glm::mat4 matrix(1.0f);
if (t.size() < 12) return matrix;
matrix[0] = glm::vec4(t[0], t[1], t[2], 0.0f);
matrix[1] = glm::vec4(t[3], t[4], t[5], 0.0f);
matrix[2] = glm::vec4(t[6], t[7], t[8], 0.0f);
matrix[3] = glm::vec4(t[9], t[10], t[11], 1.0f);
return matrix;
}
// LXFML 4: rotate `angle` degrees around (ax, ay, az), then translate
glm::mat4 AxisAngleMatrix(const tinyxml2::XMLElement* element) {
const glm::vec3 axis(element->FloatAttribute("ax"), element->FloatAttribute("ay"), element->FloatAttribute("az"));
const glm::vec3 translation(element->FloatAttribute("tx"), element->FloatAttribute("ty"), element->FloatAttribute("tz"));
glm::mat4 matrix = glm::translate(glm::mat4(1.0f), translation);
if (glm::dot(axis, axis) > 0.0f) matrix = glm::rotate(matrix, glm::radians(element->FloatAttribute("angle")), glm::normalize(axis));
return matrix;
}
bool ParseDesign(const tinyxml2::XMLElement* element, uint32_t& design) {
const char* text = element->Attribute("designID");
if (!text || !*text) return false;
for (const char* c = text; *c; c++) {
if (*c < '0' || *c > '9') return false;
}
try {
design = static_cast<uint32_t>(std::stoul(text));
} catch (...) {
return false;
}
return true;
}
glm::vec4 ToColor(const UgcBricks::Material& material) {
return glm::vec4(material.r, material.g, material.b, material.a) / 255.0f;
}
}
namespace UgcModel {
std::vector<Part> ParseLxfml(std::string_view lxfml, std::string& error) {
std::vector<Part> parts;
tinyxml2::XMLDocument doc;
if (doc.Parse(lxfml.data(), lxfml.size()) != tinyxml2::XML_SUCCESS) {
error = "the LXFML is not valid XML";
return parts;
}
const auto* root = doc.FirstChildElement("LXFML");
if (!root) {
error = "no LXFML element";
return parts;
}
if (const auto* bricks = root->FirstChildElement("Bricks")) {
for (const auto* brick = bricks->FirstChildElement("Brick"); brick; brick = brick->NextSiblingElement("Brick")) {
for (const auto* partElement = brick->FirstChildElement("Part"); partElement; partElement = partElement->NextSiblingElement("Part")) {
const auto* bone = partElement->FirstChildElement("Bone");
Part part;
if (!bone || !ParseDesign(partElement, part.designId)) continue;
const char* materials = partElement->Attribute("materials");
if (!materials) materials = partElement->Attribute("materialID");
part.materials = ParseMaterials(materials ? materials : "0");
part.transform = BoneMatrix(ParseFloats(bone->Attribute("transformation")));
parts.push_back(std::move(part));
}
}
if (!parts.empty()) return parts;
}
// LXFML 4: groups (with their own transforms) nest parts
std::function<void(const tinyxml2::XMLElement*, const glm::mat4&)> walk = [&](const tinyxml2::XMLElement* element, const glm::mat4& parent) {
for (const auto* child = element->FirstChildElement(); child; child = child->NextSiblingElement()) {
const std::string_view name = child->Name();
if (name != "Group" && name != "Part") continue;
const auto matrix = parent * AxisAngleMatrix(child);
if (name == "Group") {
walk(child, matrix);
continue;
}
Part part;
if (!ParseDesign(child, part.designId)) continue;
const char* material = child->Attribute("materialID");
part.materials = ParseMaterials(material ? material : "0");
part.transform = matrix;
parts.push_back(std::move(part));
}
};
if (const auto* scene = root->FirstChildElement("Scene")) {
for (const auto* model = scene->FirstChildElement("Model"); model; model = model->NextSiblingElement("Model")) walk(model, glm::mat4(1.0f));
}
if (parts.empty()) error = "the LXFML has no bricks";
return parts;
}
bool HasNoBricks(std::string_view lxfml) {
tinyxml2::XMLDocument doc;
if (doc.Parse(lxfml.data(), lxfml.size()) != tinyxml2::XML_SUCCESS || !doc.FirstChildElement("LXFML")) return false;
std::string error;
return ParseLxfml(lxfml, error).empty() && lxfml.find("<Part") == std::string_view::npos;
}
void Mesh::Append(const Mesh& other) {
const auto base = static_cast<uint32_t>(positions.size());
positions.insert(positions.end(), other.positions.begin(), other.positions.end());
normals.insert(normals.end(), other.normals.begin(), other.normals.end());
colors.insert(colors.end(), other.colors.begin(), other.colors.end());
if (!glow.empty() || !other.glow.empty()) {
glow.resize(base, 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());
}
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);
}
void Mesh::Transform(const glm::mat4& transform) {
const glm::mat3 normalMatrix = glm::transpose(glm::inverse(glm::mat3(transform)));
for (auto& position : positions) position = glm::vec3(transform * glm::vec4(position, 1.0f));
for (auto& normal : normals) {
const auto n = normalMatrix * normal;
const auto length = glm::length(n);
normal = length > 0.0f ? n / length : n;
}
}
bool Model::Bounds(glm::vec3& min, glm::vec3& max) const {
bool any = false;
for (const auto* mesh : { &opaque, &transparent }) {
for (const auto& position : mesh->positions) {
min = any ? glm::min(min, position) : position;
max = any ? glm::max(max, position) : position;
any = true;
}
}
return any;
}
eLook LookOf(uint32_t id, const UgcBricks::Material& material, const LookRules& rules) {
if (const auto color = rules.colors.find(id); color != rules.colors.end()) return color->second;
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, anyLook = false, anyTransparentLook = false;
for (uint32_t brick = 0; brick < parts.size(); brick++) {
const auto& part = parts[brick];
const auto design = library.GetDesign(part.designId, options.lod);
if (!design || design->empty()) {
missing.insert(part.designId);
continue;
}
model.bricks++;
const auto materialOf = [&part, &library](size_t index) {
auto id = index < part.materials.size() ? part.materials[index] : (part.materials.empty() ? 0 : part.materials[0]);
// Unknown colors are black in LU Toolbox (its name included, so black's variation too). A color LU
// Toolbox doesn't know but the client's Materials.xml has (one added to the brick database) keeps its id
// and takes its color from there.
if (id == 0 || (!UgcPalette::Linear(id) && !library.HasMaterial(id))) id = UgcPalette::FALLBACK_ID;
return id;
};
// Whether LU Toolbox's own palette colors a material (else it's one only the client's Materials.xml has)
const auto inToolbox = [](uint32_t id) { return UgcPalette::Linear(id).has_value(); };
// A brick is transparent only when all of its materials are (LU Toolbox's IS_TRANSPARENT)
bool transparent = true;
for (size_t index = 0; index < design->size(); index++) {
const auto id = index < part.materials.size() ? part.materials[index] : (part.materials.empty() ? 0 : part.materials[0]);
const auto toolboxId = materialOf(index);
const auto named = options.transparentColors.contains(luToolbox ? toolboxId : id);
transparent = transparent && (named || (luToolbox && inToolbox(toolboxId) ? UgcPalette::IsTransparent(toolboxId) : library.GetMaterial(luToolbox ? toolboxId : id).Transparent()));
}
auto& mesh = transparent ? model.transparent : model.opaque;
if (transparent) model.transparentBricks.push_back(mesh.indices.size());
const glm::mat3 normalMatrix = glm::transpose(glm::inverse(glm::mat3(part.transform)));
for (size_t index = 0; index < design->size(); index++) {
const auto& geometry = (*design)[index];
glm::vec3 linear{};
float alpha = 1.0f;
glm::vec3 glow(0.0f);
uint32_t colorId{};
if (luToolbox && !inToolbox(materialOf(index))) {
// A color only the client's Materials.xml has
colorId = materialOf(index);
const auto material = library.GetMaterial(colorId);
linear = UgcPalette::SrgbToLinear(glm::vec3(material.r, material.g, material.b) / 255.0f);
if (transparent) alpha = material.a < 255 ? material.a / 255.0f : std::clamp(options.transparentOpacity / 100.0f, 0.0f, 1.0f);
} else if (luToolbox) {
colorId = materialOf(index);
linear = *UgcPalette::Linear(colorId, options.icon);
if (transparent) alpha = std::clamp(options.transparentOpacity / 100.0f, 0.0f, 1.0f);
if (!transparent) {
if (const auto g = UgcPalette::Glow(colorId)) glow = *g;
}
} else {
colorId = index < part.materials.size() ? part.materials[index] : (part.materials.empty() ? 0 : part.materials[0]);
const auto material = library.GetMaterial(colorId);
linear = UgcPalette::SrgbToLinear(glm::vec3(material.r, material.g, material.b) / 255.0f);
if (transparent) alpha = material.a < 255 ? material.a / 255.0f : std::clamp(options.transparentOpacity / 100.0f, 0.0f, 1.0f);
}
if (options.colorVariation > 0.0f) {
const float variation = options.colorVariation * (luToolbox ? UgcPalette::VariationScale(colorId) : 1.0f);
linear = UgcPalette::ApplyVariation(linear, variation, UgcPalette::BrickRandom(options.seed, brick, colorId));
}
if (!options.icon && options.brightness != 100.0f) linear *= std::max(options.brightness, 0.0f) / 100.0f;
if (options.satinColors.contains(colorId)) {
// Satin: milky, and less see-through than clear plastic
linear = glm::mix(linear, glm::vec3(1.0f), std::clamp(options.satinWhiten / 100.0f, 0.0f, 1.0f));
if (transparent) alpha = std::clamp(options.satinOpacity / 100.0f, 0.0f, 1.0f);
}
const glm::vec4 color(UgcPalette::LinearToSrgb(linear), alpha);
anyGlow = anyGlow || glow != glm::vec3(0.0f);
auto look = LookOf(colorId, library.GetMaterial(colorId), options.looks);
if (transparent && look != eLook::GLITTER) look = eLook::PLASTIC;
(transparent ? anyTransparentLook : anyLook) = (transparent ? anyTransparentLook : 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++) {
const glm::vec3 position(geometry.positions[v * 3], geometry.positions[v * 3 + 1], geometry.positions[v * 3 + 2]);
glm::vec3 normal(geometry.normals[v * 3], geometry.normals[v * 3 + 1], geometry.normals[v * 3 + 2]);
normal = normalMatrix * normal;
const auto length = glm::length(normal);
if (length > 0.0f) normal /= length;
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);
mesh.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();
if (!anyTransparentLook) model.transparent.looks.clear();
model.missingDesigns.assign(missing.begin(), missing.end());
return model;
}
std::vector<std::pair<float, float>> LodRanges(const std::vector<uint32_t>& used, const LodDistances& d) {
// LU Toolbox's setup_lod_data ("DYNAMIC LOD HELL"), by the set of levels there are
const std::set<uint32_t> set(used.begin(), used.end());
const auto is = [&set](std::initializer_list<uint32_t> levels) { return set == std::set<uint32_t>(levels); };
std::vector<std::pair<float, float>> ranges;
for (const auto level : used) {
std::pair<float, float> range{ 0.0f, 0.0f };
if (set.size() == 1) {
range = { d.lod0, d.cull };
} else if (level == 0) {
range.first = d.lod0;
if (is({ 0, 2 }) || is({ 0, 2, 3 })) range.second = d.lod2;
else if (is({ 0, 3 })) range.second = d.lod3;
else range.second = d.lod1;
} else if (level == 1) {
if (is({ 0, 1 })) range = { d.lod1, d.cull };
else if (is({ 1, 2 }) || is({ 1, 2, 3 })) range = { d.lod0, d.lod2 };
else if (is({ 0, 1, 3 })) range = { d.lod1, d.lod3 };
else if (is({ 1, 3 })) range = { d.lod0, d.lod3 };
else if (is({ 0, 1, 2 }) || is({ 0, 1, 2, 3 })) range = { d.lod1, d.lod2 };
} else if (level == 2) {
if (is({ 0, 2 }) || is({ 1, 2 }) || is({ 0, 1, 2 })) range = { d.lod2, d.cull };
else if (is({ 2, 3 })) range = { d.lod0, d.lod3 };
else if (is({ 0, 2, 3 }) || is({ 1, 2, 3 }) || is({ 0, 1, 2, 3 })) range = { d.lod2, d.lod3 };
} else if (level == 3) {
range = { d.lod3, d.cull };
}
ranges.push_back(range);
}
return ranges;
}
Model FromNif(const NifFile::Model& nif, const std::map<int32_t, eLook>& tagLooks) {
Model model;
for (const auto& source : nif.meshes) {
Mesh mesh;
const size_t count = source.positions.size() / 3;
const bool vertexColors = source.material.vertexColorMode == 2 && source.colors.size() == count * 4;
const glm::vec4 materialColor(source.material.diffuse[0], source.material.diffuse[1], source.material.diffuse[2], source.material.alpha);
for (size_t v = 0; v < count; v++) {
mesh.positions.emplace_back(source.positions[v * 3], source.positions[v * 3 + 1], source.positions[v * 3 + 2]);
if (source.normals.size() == count * 3) mesh.normals.emplace_back(source.normals[v * 3], source.normals[v * 3 + 1], source.normals[v * 3 + 2]);
glm::vec4 color = materialColor;
if (vertexColors) color *= glm::vec4(source.colors[v * 4], source.colors[v * 4 + 1], source.colors[v * 4 + 2], source.colors[v * 4 + 3]) / 255.0f;
mesh.colors.push_back(color);
}
mesh.indices.assign(source.indices.begin(), source.indices.end());
// Normals from the faces when the file has none
if (mesh.normals.size() != count) {
mesh.normals.assign(count, glm::vec3(0.0f));
for (size_t i = 0; i + 2 < mesh.indices.size(); i += 3) {
const auto& a = mesh.positions[mesh.indices[i]];
const auto face = glm::cross(mesh.positions[mesh.indices[i + 1]] - a, mesh.positions[mesh.indices[i + 2]] - a);
for (int k = 0; k < 3; k++) mesh.normals[mesh.indices[i + k]] += face;
}
for (auto& normal : mesh.normals) {
const auto length = glm::length(normal);
normal = length > 0.0f ? normal / length : glm::vec3(0.0f, 1.0f, 0.0f);
}
}
// 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); look != tagLooks.end() && look->second != eLook::PLASTIC && (!seeThrough || look->second == eLook::GLITTER)) {
mesh.looks.assign(mesh.positions.size(), look->second);
}
(seeThrough ? model.transparent : model.opaque).Append(mesh);
}
return model;
}
std::vector<Mesh> SplitAt(const Mesh& mesh, const std::vector<size_t>& starts) {
std::vector<Mesh> pieces;
std::unordered_map<uint32_t, uint32_t> remap;
for (size_t i = 0; i < starts.size(); i++) {
const size_t first = starts[i], last = std::min(i + 1 < starts.size() ? starts[i + 1] : mesh.indices.size(), mesh.indices.size());
if (first >= last) continue;
Mesh piece;
remap.clear();
for (size_t k = first; k < last; k++) {
const auto source = mesh.indices[k];
auto [it, added] = remap.try_emplace(source, static_cast<uint32_t>(piece.positions.size()));
if (added) {
piece.positions.push_back(mesh.positions[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.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);
}
pieces.push_back(std::move(piece));
}
return pieces;
}
void KeepTriangles(Mesh& mesh, const std::vector<bool>& keep) {
Mesh kept;
std::vector<uint32_t> remap(mesh.positions.size(), UINT32_MAX);
for (size_t t = 0; t < mesh.TriangleCount(); t++) {
if (t >= keep.size() || !keep[t]) continue;
for (int k = 0; k < 3; k++) {
const auto source = mesh.indices[t * 3 + k];
if (remap[source] == UINT32_MAX) {
remap[source] = static_cast<uint32_t>(kept.positions.size());
kept.positions.push_back(mesh.positions[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.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]);
}
}
mesh = std::move(kept);
}
std::vector<Mesh> Split(const Mesh& mesh, size_t maxVertices, size_t maxTriangles) {
std::vector<Mesh> pieces;
if (mesh.positions.size() <= maxVertices && mesh.TriangleCount() <= maxTriangles) {
if (!mesh.Empty()) pieces.push_back(mesh);
return pieces;
}
Mesh current;
std::unordered_map<uint32_t, uint32_t> remap;
const auto flush = [&]() {
if (!current.Empty()) pieces.push_back(std::move(current));
current = Mesh{};
remap.clear();
};
for (size_t i = 0; i + 2 < mesh.indices.size(); i += 3) {
size_t newVertices = 0;
for (int k = 0; k < 3; k++) newVertices += remap.contains(mesh.indices[i + k]) ? 0 : 1;
if (current.positions.size() + newVertices > maxVertices || current.TriangleCount() + 1 > maxTriangles) flush();
for (int k = 0; k < 3; k++) {
const auto source = mesh.indices[i + k];
auto [it, added] = remap.try_emplace(source, static_cast<uint32_t>(current.positions.size()));
if (added) {
current.positions.push_back(mesh.positions[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.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);
}
}
flush();
return pieces;
}
std::vector<Mesh> Divide(const Mesh& mesh, size_t maxVertices, size_t maxTriangles) {
if (mesh.Empty()) return {};
if (mesh.positions.size() <= maxVertices && mesh.TriangleCount() <= maxTriangles) return { mesh };
// Connected pieces (vertices joined by triangles), so a brick's faces stay together
std::vector<uint32_t> parent(mesh.positions.size());
for (uint32_t i = 0; i < parent.size(); i++) parent[i] = i;
const std::function<uint32_t(uint32_t)> find = [&](uint32_t x) {
while (parent[x] != x) x = parent[x] = parent[parent[x]];
return x;
};
for (size_t i = 0; i + 2 < mesh.indices.size(); i += 3) {
const auto a = find(mesh.indices[i]);
parent[find(mesh.indices[i + 1])] = a;
parent[find(mesh.indices[i + 2])] = a;
}
// divide_mesh: vertices below the mean along the longest side of the bounds, and everything linked to them
glm::vec3 min = mesh.positions[0], max = mesh.positions[0], mean(0.0f);
for (const auto& p : mesh.positions) {
min = glm::min(min, p);
max = glm::max(max, p);
mean += p;
}
mean /= static_cast<float>(mesh.positions.size());
const auto size = max - min;
const int axis = size.x >= size.y && size.x >= size.z ? 0 : size.y >= size.z ? 1 : 2;
std::vector<bool> below(mesh.positions.size(), false);
for (uint32_t v = 0; v < mesh.positions.size(); v++) {
if (mesh.positions[v][axis] < mean[axis]) below[find(v)] = true;
}
Mesh halves[2];
std::vector<uint32_t> remap(mesh.positions.size(), UINT32_MAX);
for (size_t i = 0; i + 2 < mesh.indices.size(); i += 3) {
auto& half = halves[below[find(mesh.indices[i])] ? 1 : 0];
for (int k = 0; k < 3; k++) {
const auto source = mesh.indices[i + k];
if (remap[source] == UINT32_MAX) {
remap[source] = static_cast<uint32_t>(half.positions.size());
half.positions.push_back(mesh.positions[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.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]);
}
}
// LU Toolbox gives up below a 10% share; this splits the old way then
const float share = static_cast<float>(halves[1].positions.size()) / static_cast<float>(mesh.positions.size());
if (std::min(share, 1.0f - share) < 0.1f) return Split(mesh, maxVertices, maxTriangles);
std::vector<Mesh> pieces;
for (const auto& half : halves) {
for (auto& piece : Divide(half, maxVertices, maxTriangles)) pieces.push_back(std::move(piece));
}
return pieces;
}
}

160
dUgcServer/Model/UgcModel.h Normal file
View File

@@ -0,0 +1,160 @@
#pragma once
#include <array>
#include <cstdint>
#include <map>
#include <set>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include <glm/glm.hpp>
#include "UgcBricks.h"
namespace NifFile {
struct Model;
}
/**
* Brick models as triangle meshes: LXFML parts, the mesh built from them (opaque and transparent bricks apart, with
* the material colors as vertex colors) and the mesh of a client .nif. Pure apart from BrickLibrary's file reads.
*/
namespace UgcModel {
// A brick in a model: its primitive, materials (one per geometry part) and where it is
struct Part {
uint32_t designId{};
std::vector<uint32_t> materials;
glm::mat4 transform{ 1.0f };
};
// The parts of an LXFML 5 (Bricks/Brick/Part with bones) or 4 (Scene/Model/Group/Part with axis angles) model.
// Empty with `error` set when it can't be read.
std::vector<Part> ParseLxfml(std::string_view lxfml, std::string& error);
// Whether an LXFML reads but has no bricks at all (nothing to make; not a failure)
bool HasNoBricks(std::string_view lxfml);
/**
* How a 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, glow
* or glitter. Transparent bricks are plastic or glitter.
*/
enum class eLook : uint8_t { PLASTIC = 0, METAL, BRUSHED, GLOW, GLITTER };
constexpr size_t LOOK_COUNT = 5;
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 (transparent meshes: plastic or glitter)
std::vector<uint32_t> indices;
size_t TriangleCount() const { return indices.size() / 3; }
bool Empty() const { return indices.empty(); }
void Append(const Mesh& other);
void Transform(const glm::mat4& transform);
};
struct Model {
Mesh opaque;
Mesh transparent;
std::vector<uint32_t> missingDesigns; // designs without geometry in the client, skipped
std::vector<size_t> transparentBricks; // where each transparent brick's triangles start in transparent.indices
size_t bricks{};
bool Empty() const { return opaque.Empty() && transparent.Empty(); }
// The bounds of every vertex; false when there are none
bool Bounds(glm::vec3& min, glm::vec3& max) const;
};
enum class ePalette {
LU_TOOLBOX, // LU Toolbox's colors (UgcPalette), what its importer colors models with
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), and colors named in the settings. A named color wins, then glow over metal;
* transparent bricks are plastic unless their color is glitter.
*/
struct LookRules {
std::map<uint32_t, eLook> colors; // LEGO color ids given a look by the settings (brushed_colors, glitter_colors)
std::map<std::string, eLook> materialTypes{ { "shinySteel", eLook::METAL }, { "brushedSteel", eLook::BRUSHED }, { "matteSteel", eLook::BRUSHED },
{ "glitter", eLook::GLITTER } };
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%)
uint64_t seed{}; // of the variation's random numbers
float transparentOpacity{ 58.82f }; // percent, transparent bricks' vertex alpha (LU Toolbox palette only)
float brightness{ 100.0f }; // percent, the models' vertex colors (not icons'); 100: as the palette has them
std::set<uint32_t> transparentColors; // color ids drawn transparent whatever Materials.xml says (129: its alpha is 255)
bool icon{}; // the icon renderer's color corrections
uint32_t lod{}; // brickprimitives level
LookRules looks; // which colors are metal and glow (Mesh::looks)
// Satin (opal) colors: transparent bricks of these colors get satinOpacity (percent) as their vertex alpha
// instead of the transparent opacity, and every brick of them has its color moved satinWhiten percent towards
// white (milky). The client has no satin shader: they stay in the transparent group. Empty: none.
std::set<uint32_t> satinColors;
float satinOpacity{ 75.0f };
float satinWhiten{ 20.0f };
};
/**
* The mesh of a model's parts, colored as LU Toolbox's Process Model does: a brick is transparent only when all of
* its materials are, each material of each brick has its brightness shifted by the color variation (the same
* random number for a brick's material in every LOD and every time), vertex colors are sRGB with alpha 1 for
* opaque bricks and the transparent opacity for transparent ones.
*/
Model Build(const std::vector<Part>& parts, UgcBricks::BrickLibrary& library, const BuildOptions& options = {});
/**
* The distance range (near, far) of each LOD LU Toolbox makes, for the brickprimitives levels in `used` (0 to 3),
* from its settings (lod0..lod3, cull): its setup_lod_data, which picks the ranges by which levels are there.
* {0, 0} for a level it has no range for.
*/
struct LodDistances {
float lod0{ 0.0f };
float lod1{ 50.0f };
float lod2{ 100.0f };
float lod3{ 280.0f };
float cull{ 10000.0f };
};
std::vector<std::pair<float, float>> LodRanges(const std::vector<uint32_t>& used, const LodDistances& distances);
/**
* Splits a mesh the way LU Toolbox's divide_mesh does while it has too many vertices (or triangles): at the mean
* of its vertices along its longest side, keeping connected pieces whole. Falls back to Split when that can't
* divide it.
*/
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). `tagLooks`:
// the look of the opaque shapes whose multishader tag (NifFile::ShaderTag, a mapShaders id) is listed, and of the
// transparent ones when it is GLITTER
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);
// Keeps the triangles whose flag is set (and the vertices they use)
void KeepTriangles(Mesh& mesh, const std::vector<bool>& keep);
// Splits a mesh into pieces the .nif format can hold (at most `maxVertices` vertices and `maxTriangles` triangles)
std::vector<Mesh> Split(const Mesh& mesh, size_t maxVertices = 65535, size_t maxTriangles = 65535);
}

View File

@@ -0,0 +1,132 @@
#include "UgcModular.h"
#include <deque>
#include <set>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/quaternion.hpp>
#include "tinyxml2.h"
namespace {
const tinyxml2::XMLElement* Child(const tinyxml2::XMLElement* element, const char* name) {
return element ? element->FirstChildElement(name) : nullptr;
}
}
namespace UgcModular {
std::optional<BuildInfo> ParseBuild(std::string_view xml) {
tinyxml2::XMLDocument doc;
if (doc.Parse(xml.data(), xml.size()) != tinyxml2::XML_SUCCESS) return std::nullopt;
const auto* root = doc.FirstChildElement("ModularBuild");
const auto* topology = Child(root, "topology");
if (!topology) return std::nullopt;
BuildInfo build;
if (const auto* rootPart = Child(topology, "rootPart")) build.rootPart = rootPart->UnsignedAttribute("value");
if (const auto* count = Child(topology, "numberOfParts")) build.parts = count->UnsignedAttribute("value");
for (const auto* connection = topology->FirstChildElement("connection"); connection; connection = connection->NextSiblingElement("connection")) {
const char* location = connection->Attribute("myLocation");
build.connections.push_back({ connection->UnsignedAttribute("myPartid"), location ? location : "", connection->UnsignedAttribute("connectingPart") });
}
if (const auto* rotation = Child(Child(Child(root, "Placement"), "AdditionalModelRotation"), "Rotation")) {
glm::quat q(rotation->FloatAttribute("w", 1.0f), rotation->FloatAttribute("x"), rotation->FloatAttribute("y"), rotation->FloatAttribute("z"));
if (glm::length(q) > 0.0f) build.additionalRotation = glm::mat4_cast(glm::normalize(q));
}
return build;
}
std::map<std::string, glm::vec3> ParseModuleConnections(std::string_view xml) {
std::map<std::string, glm::vec3> connections;
tinyxml2::XMLDocument doc;
if (doc.Parse(xml.data(), xml.size()) != tinyxml2::XML_SUCCESS) return connections;
const auto* root = doc.FirstChildElement("ModuleInfo");
if (!root) return connections;
for (const auto* connection = root->FirstChildElement("connection"); connection; connection = connection->NextSiblingElement("connection")) {
const char* name = connection->Attribute("name");
const auto* translation = connection->FirstChildElement("translation");
if (!name || !translation) continue;
connections[name] = glm::vec3(translation->FloatAttribute("x"), translation->FloatAttribute("y"), translation->FloatAttribute("z"));
}
return connections;
}
std::vector<uint32_t> ParseModuleLots(std::string_view ldf) {
std::vector<uint32_t> lots;
size_t start = 0;
while (start <= ldf.size()) {
size_t end = ldf.find_first_of("+;,", start);
if (end == std::string_view::npos) end = ldf.size();
auto item = ldf.substr(start, end - start);
if (const auto colon = item.find(':'); colon != std::string_view::npos) item = item.substr(colon + 1);
uint32_t value = 0;
bool digits = !item.empty();
for (const char c : item) {
if (c < '0' || c > '9') {
digits = false;
break;
}
value = value * 10 + static_cast<uint32_t>(c - '0');
}
if (digits && value != 0) lots.push_back(value);
start = end + 1;
}
return lots;
}
UgcModel::Model Assemble(const BuildInfo& build, const std::vector<Module>& modules, std::string& warnings) {
UgcModel::Model model;
std::map<uint32_t, const Module*> byPart;
for (const auto& module : modules) byPart.emplace(module.partCode, &module);
std::map<uint32_t, glm::mat4> placed;
std::deque<uint32_t> pending;
if (byPart.contains(build.rootPart)) {
placed[build.rootPart] = glm::mat4(1.0f);
pending.push_back(build.rootPart);
} else if (!modules.empty()) {
warnings += "no module for the root part " + std::to_string(build.rootPart) + "; ";
placed[modules.front().partCode] = glm::mat4(1.0f);
pending.push_back(modules.front().partCode);
}
while (!pending.empty()) {
const auto part = pending.front();
pending.pop_front();
const auto* parent = byPart.at(part);
for (const auto& connection : build.connections) {
if (connection.part != part || placed.contains(connection.connectingPart)) continue;
const auto childIt = byPart.find(connection.connectingPart);
if (childIt == byPart.end()) continue; // optional parts
glm::vec3 anchor(0.0f);
if (const auto node = parent->nif.nodes.find(connection.location); node != parent->nif.nodes.end()) {
anchor = glm::vec3(node->second.translation[0], node->second.translation[1], node->second.translation[2]);
} else if (const auto offset = parent->connections.find(connection.location); offset != parent->connections.end()) {
anchor = offset->second;
} else {
warnings += "part " + std::to_string(part) + " has no " + connection.location + "; ";
}
const auto& child = *childIt->second;
if (const auto node = child.nif.nodes.find(connection.location); node != child.nif.nodes.end()) {
anchor -= glm::vec3(node->second.translation[0], node->second.translation[1], node->second.translation[2]);
}
placed[connection.connectingPart] = placed[part] * glm::translate(glm::mat4(1.0f), anchor);
pending.push_back(connection.connectingPart);
}
}
for (const auto& module : modules) {
const auto it = placed.find(module.partCode);
if (it == placed.end()) {
warnings += "part " + std::to_string(module.partCode) + " isn't connected; ";
continue;
}
auto mesh = UgcModel::FromNif(module.nif);
mesh.opaque.Transform(it->second);
mesh.transparent.Transform(it->second);
model.opaque.Append(mesh.opaque);
model.transparent.Append(mesh.transparent);
model.bricks++;
}
return model;
}
}

View File

@@ -0,0 +1,57 @@
#pragma once
#include <cstdint>
#include <map>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
#include <glm/glm.hpp>
#include "NifFile.h"
#include "UgcModel.h"
/**
* Modular builds (cars, rockets) put together from their modules the way the CDClient describes it:
* ModularBuildComponent.xml has the topology (a root part and which part connects to which named location of which
* other part), ModuleComponent.xml each module's connection offsets, and each module's render asset has the named
* attach-point nodes. Pure.
*/
namespace UgcModular {
struct Connection {
uint32_t part{}; // the part the location is on
std::string location; // node name on that part
uint32_t connectingPart{}; // the part placed there
};
struct BuildInfo {
uint32_t rootPart{};
uint32_t parts{};
std::vector<Connection> connections;
glm::mat4 additionalRotation{ 1.0f }; // Placement/AdditionalModelRotation
};
// ModularBuildComponent.xml; nullopt when it has no topology
std::optional<BuildInfo> ParseBuild(std::string_view xml);
// ModuleComponent.xml's connection offsets, by location name
std::map<std::string, glm::vec3> ParseModuleConnections(std::string_view xml);
// The LOTs in a ugc_modular_build.ldf_config ("1:4713+1:4714+1:4715", also with ';' or ',' between)
std::vector<uint32_t> ParseModuleLots(std::string_view ldf);
struct Module {
uint32_t partCode{};
NifFile::Model nif;
std::map<std::string, glm::vec3> connections; // from ParseModuleConnections
};
/**
* The modules placed per the build's topology, starting at the root part: a connection puts the connecting part
* where the parent's node of the location's name is (or the parent module's connection offset of that name when
* its mesh has no such node), less where the connecting part's own node of that name is, if it has one.
* Parts without a module are skipped; `warnings` says what was.
*/
UgcModel::Model Assemble(const BuildInfo& build, const std::vector<Module>& modules, std::string& warnings);
}