feat(ugc): glitter flecks (LEGO-AnimUV) and milky satin in made models

Glitter colors (Materials.xml type glitter, glitter_colors 114,117) go into
S21_Glitter_Model and, transparent, S21_GlitterAlpha_Model (shader_glitter,
default 21, LEGO-AnimUV). Their shapes get box-projected UVs, an
NiTexturingProperty with a stored 128 px mipmapped fleck texture
(NiSourceTexture + NiPersistentSrcTextureRendererData, as the client's own
env_ag_ocean-maelstrom.nif) and two NiTextureTransformControllers looping
the base map's translation (glitter_size, glitter_density, glitter_speed).
The shader lays the texture over the vertex color by its alpha and outputs
the vertex alpha, so transparent glitter blends as S01_Alpha does.

Satin colors (satin_colors, LEGO's opal colors) stay in S01_Alpha but get
satin_opacity and are whitened by satin_whiten.

NifFile reads the base map's scroll speed (uvScroll) from the controllers;
the icon draws still flecks, the UGC 3D view and the LXFML viewers moving
ones. stats.json counts the glitter groups. With shader_glitter 0 and no
satin colors the files are the same bytes as before (tested). Also keeps
glow_emissive for the icon (it was reset by the icon settings).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 12:53:52 -05:00
parent 49c1486771
commit dfe4cc523a
24 changed files with 966 additions and 93 deletions

View File

@@ -2,6 +2,7 @@
set(DUGC_SOURCES
"UgcBricks.cpp"
"UgcFormats.cpp"
"UgcGlitter.cpp"
"UgcIconParams.cpp"
"UgcIconPose.cpp"
"UgcJobs.cpp"
@@ -40,5 +41,5 @@ target_link_libraries(UgcServer ${COMMON_LIBRARIES} dWeb dServer dUgc)
# Rendering is far too slow unoptimized (minutes a model instead of seconds), so it is optimized in every build type
if(NOT MSVC)
set_source_files_properties("UgcRender.cpp" "UgcModel.cpp" "UgcPalette.cpp" PROPERTIES COMPILE_OPTIONS "-O2")
set_source_files_properties("UgcRender.cpp" "UgcModel.cpp" "UgcPalette.cpp" "UgcGlitter.cpp" PROPERTIES COMPILE_OPTIONS "-O2")
endif()

View File

@@ -138,7 +138,8 @@ namespace {
out.I32(-1); // collision object
}
std::string TriShapeData(const UgcModel::Mesh& mesh) {
// `glitter`: with a UV set projected for the glitter texture (UgcGlitter::Uv)
std::string TriShapeData(const UgcModel::Mesh& mesh, const UgcGlitter::Params* glitter = nullptr) {
Writer out;
const auto count = static_cast<uint16_t>(mesh.positions.size());
out.I32(0); // group ID
@@ -154,8 +155,9 @@ namespace {
min = glm::min(min, p);
max = glm::max(max, p);
}
out.U16(0); // data flags: no texture coordinates or tangents
const bool normals = mesh.normals.size() == mesh.positions.size();
const bool uvs = glitter && normals;
out.U16(uvs ? 1 : 0); // data flags: the number of UV sets, no tangents
out.U8(normals ? 1 : 0);
if (normals) {
for (const auto& n : mesh.normals) {
@@ -181,6 +183,13 @@ namespace {
out.Float(std::clamp(c.a, 0.0f, 1.0f));
}
}
if (uvs) {
for (size_t v = 0; v < mesh.positions.size(); v++) {
const auto uv = UgcGlitter::Uv(mesh.positions[v], mesh.normals[v], glitter->tile);
out.Float(uv.x);
out.Float(uv.y);
}
}
out.U16(0x4000); // consistency: static
out.I32(-1); // additional data
const auto triangles = static_cast<uint16_t>(mesh.indices.size() / 3);
@@ -226,9 +235,134 @@ namespace {
return std::move(material.Data());
}
/**
* The glitter groups' NiTexturingProperty (made once a file), as the client's own animated textures have it
* (res/mesh/env/env_ag_ocean-maelstrom.nif): the base map wrapping, with a texture transform (Maya method,
* center 0.5), its source stored in the file, and NiTextureTransformControllers on the property looping the
* transform's translation (flags 0x48: active, looping, app time; frequency 1) through an NiFloatInterpolator
* and linear NiFloatData from 0 to 1 tile. The client's NifHasAnimatedControllers (0x00bf4160) finds the
* property's first controller and marks the object animated. Apply mode decal: what fixed function would do
* with it is what the shader does (the texture over the vertex color by its alpha, the vertex alpha kept).
*/
int32_t GlitterTexturing(const UgcGlitter::Params& glitter) {
if (m_Glitter >= 0) return m_Glitter;
m_Glitter = m_Nif.Reserve("NiTexturingProperty");
std::vector<std::pair<uint32_t, float>> motions; // TexTransform (0 translate U, 1 translate V), seconds a tile
if (glitter.PeriodU() > 0.0f) motions.emplace_back(0, glitter.PeriodU());
if (glitter.PeriodV() > 0.0f) motions.emplace_back(1, glitter.PeriodV());
std::vector<int32_t> controllers;
for (size_t i = 0; i < motions.size(); i++) controllers.push_back(m_Nif.Reserve("NiTextureTransformController"));
for (size_t i = 0; i < motions.size(); i++) {
const auto [operation, period] = motions[i];
Writer data;
data.U32(2); // keys
data.U32(1); // linear
data.Float(0.0f);
data.Float(0.0f);
data.Float(period);
data.Float(1.0f);
const auto interpolator = m_Nif.Reserve("NiFloatInterpolator");
const auto dataBlock = m_Nif.Add("NiFloatData", std::move(data.Data()));
Writer value;
value.Float(0.0f);
value.I32(dataBlock);
m_Nif.Fill(interpolator, std::move(value.Data()));
Writer controller;
controller.I32(i + 1 < controllers.size() ? controllers[i + 1] : -1); // next controller
controller.U16(0x48); // active, loop, app time (as the client's files)
controller.Float(1.0f); // frequency
controller.Float(0.0f); // phase
controller.Float(0.0f); // start
controller.Float(period); // stop
controller.I32(m_Glitter); // target
controller.I32(interpolator);
controller.U8(0); // not a shader map
controller.U32(0); // the base map
controller.U32(operation);
m_Nif.Fill(controllers[i], std::move(controller.Data()));
}
// The texture: white, its alpha the flecks, mipmapped, 32-bit as the client's stored textures are (B, G, R, A)
const auto source = m_Nif.Reserve("NiSourceTexture");
Writer pixels;
pixels.U32(1); // RGBA
pixels.U8(32); // bits per pixel
pixels.U32(0xFFFFFFFF); // renderer hint
pixels.U32(0); // extra data
pixels.U8(1); // flags
pixels.U32(0); // tiling
pixels.U8(0); // sRGB
for (const uint32_t channel : { 2u, 1u, 0u, 3u }) { // blue, green, red, alpha
pixels.U32(channel);
pixels.U32(0); // convention: fixed
pixels.U8(8);
pixels.U8(0); // unsigned
}
pixels.I32(-1); // palette
const auto mipmaps = UgcGlitter::Mipmaps(UgcGlitter::FleckAlpha(glitter.flecks));
pixels.U32(static_cast<uint32_t>(mipmaps.size()));
pixels.U32(4); // bytes per pixel
uint32_t offset = 0;
for (size_t level = 0; level < mipmaps.size(); level++) {
const uint32_t side = static_cast<uint32_t>(UgcGlitter::TEXTURE_SIZE) >> level;
pixels.U32(side);
pixels.U32(side);
pixels.U32(offset);
offset += static_cast<uint32_t>(mipmaps[level].size()) * 4;
}
pixels.U32(offset); // pixels
pixels.U32(offset); // padded
pixels.U32(1); // faces
pixels.U32(3); // platform: DX9
for (const auto& level : mipmaps) {
for (const auto a : level) {
pixels.U8(255);
pixels.U8(255);
pixels.U8(255);
pixels.U8(a);
}
}
const auto pixelData = m_Nif.Add("NiPersistentSrcTextureRendererData", std::move(pixels.Data()));
Writer texture;
WriteNet(texture, -1);
texture.U8(0); // stored in the file
texture.I32(m_Nif.String("ugc_glitter.dds"));
texture.I32(pixelData);
texture.U32(6); // pixel layout: default
texture.U32(2); // mipmaps: default
texture.U32(3); // alpha: default
texture.U8(1); // static
texture.U8(0); // direct render
texture.U8(1); // persist render data
m_Nif.Fill(source, std::move(texture.Data()));
Writer texturing;
texturing.I32(-1); // name
texturing.U32(0); // extra data
texturing.I32(controllers.empty() ? -1 : controllers[0]);
texturing.U16(1 << 1); // apply mode decal
texturing.U32(9); // texture slots
texturing.U8(1); // base map
texturing.I32(source);
texturing.U16(0x3200); // wrap S and T, trilinear, UV set 0
texturing.U8(1); // texture transform
texturing.Float(0.0f); // translation
texturing.Float(0.0f);
texturing.Float(1.0f); // scale
texturing.Float(1.0f);
texturing.Float(0.0f); // rotation
texturing.U32(2); // Maya
texturing.Float(0.5f); // center
texturing.Float(0.5f);
for (int slot = 1; slot < 9; slot++) texturing.U8(0); // dark, detail, gloss, glow, bump, normal, parallax, decal
texturing.U32(0); // shader maps
m_Nif.Fill(m_Glitter, std::move(texturing.Data()));
return m_Glitter;
}
// 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) {
// emissive color, 0 for the shared material without one; `glitter`: with the glitter texture
int32_t Shape(const std::string& name, const UgcModel::Mesh* mesh, bool transparent, float emissive = 0.0f, const UgcGlitter::Params* glitter = nullptr) {
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
@@ -251,8 +385,9 @@ namespace {
material = it->second;
}
std::vector<int32_t> properties{ material, m_Alpha, m_Specular, m_VertexColor };
if (glitter) properties.push_back(GlitterTexturing(*glitter));
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, glitter));
Writer tri;
WriteAv(tri, m_Nif.String(name), properties, SHAPE_FLAGS);
tri.I32(dataBlock);
@@ -271,6 +406,7 @@ namespace {
int32_t m_Alpha{ -1 };
int32_t m_Specular{ -1 };
std::map<float, int32_t> m_Emissive; // emissive color -> its material
int32_t m_Glitter{ -1 }; // the glitter groups' NiTexturingProperty
};
}
@@ -304,7 +440,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, group.emissive);
const auto block = properties.Shape(group.name, piece, group.transparent, group.emissive, group.glitter);
if (block >= 0) shapes.push_back(block);
}
nif.Fill(level, NodeData(nif.String(lod.name), shapes));

View File

@@ -7,6 +7,7 @@
#include <string_view>
#include <vector>
#include "UgcGlitter.h"
#include "UgcModel.h"
#include "UgcRender.h"
@@ -43,13 +44,19 @@ namespace UgcFormats {
// 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 glitter of the UGC server's glitter groups (docs/UgcServer.md, "Metal and glow"): the shapes get UVs
// (UgcGlitter::Uv) and an NiTexturingProperty whose base map is the fleck texture stored in the file
// (NiSourceTexture, NiPersistentSrcTextureRendererData), its texture transform's translation looped by an
// NiTextureTransformController for U and one for V. Null: none.
const UgcGlitter::Params* glitter{};
};
/**
* 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; a group with
* an emissive color gets a material of its own.
* an emissive color gets a material of its own, a glitter group the glitter texture's NiTexturingProperty (one
* for every glitter group of the file).
*/
std::string WriteLodNif(const std::string& rootName, const std::vector<NifLodGroup>& groups);

73
dUgcServer/UgcGlitter.cpp Normal file
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;
}
}

41
dUgcServer/UgcGlitter.h Normal file
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

@@ -78,6 +78,35 @@ namespace UgcJobs {
for (size_t look = 0; look < UgcModel::LOOK_COUNT; look++) pieces[look] = UgcModel::Divide((*split)[look]);
return pieces;
}
// A model's transparent bricks as the .nif's shapes: one per brick (or all together, `combine`), the glitter
// ones apart when `glitterApart`
std::array<std::vector<UgcModel::Mesh>, 2> DivideTransparent(const UgcModel::Model& model, bool combine, bool glitterApart) {
std::array<std::vector<UgcModel::Mesh>, 2> out;
std::array<bool, UgcModel::LOOK_COUNT> separate{};
separate[static_cast<size_t>(UgcModel::eLook::GLITTER)] = glitterApart;
const auto glitter = static_cast<size_t>(UgcModel::eLook::GLITTER);
if (combine) {
const auto split = UgcModel::SplitLooks(model.transparent, separate);
if (!split) {
out[0] = UgcModel::Divide(model.transparent);
} else {
out[0] = UgcModel::Divide((*split)[0]);
out[1] = UgcModel::Divide((*split)[glitter]);
}
return out;
}
for (auto& piece : UgcModel::SplitAt(model.transparent, model.transparentBricks)) {
const auto split = UgcModel::SplitLooks(piece, separate);
if (!split) {
out[0].push_back(std::move(piece));
continue;
}
if (!(*split)[0].Empty()) out[0].push_back(std::move((*split)[0]));
if (!(*split)[glitter].Empty()) out[1].push_back(std::move((*split)[glitter]));
}
return out;
}
}
uint32_t Shaders::TagOf(UgcModel::eLook look) const {
@@ -85,23 +114,26 @@ namespace UgcJobs {
case UgcModel::eLook::METAL: return metal;
case UgcModel::eLook::BRUSHED: return brushed;
case UgcModel::eLook::GLOW: return glow;
case UgcModel::eLook::GLITTER: return glitter;
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 }) {
std::map<int32_t, UgcModel::eLook> looks{ { 88, UgcModel::eLook::METAL }, { 89, UgcModel::eLook::BRUSHED }, { 46, UgcModel::eLook::GLOW },
{ 21, UgcModel::eLook::GLITTER } };
for (const auto look : { UgcModel::eLook::METAL, UgcModel::eLook::BRUSHED, UgcModel::eLook::GLOW, UgcModel::eLook::GLITTER }) {
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 (transparent && look != UgcModel::eLook::GLITTER) 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";
const char* name = look == UgcModel::eLook::METAL ? "_Metal_Model" : look == UgcModel::eLook::BRUSHED ? "_Brushed_Model" :
look == UgcModel::eLook::GLOW ? "_Glow_Model" : transparent ? "_GlitterAlpha_Model" : "_Glitter_Model";
return ("S" + std::string(tag.size() < 2 ? "0" : "") + tag + name).substr(0, 60);
}
@@ -150,7 +182,9 @@ 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<LookPieces> opaquePieces;
std::vector<std::vector<UgcModel::Mesh>> transparentPieces;
// Per level: the transparent bricks' pieces, plastic and (with the glitter group on) glitter
using TransparentPieces = std::array<std::vector<UgcModel::Mesh>, 2>;
std::vector<TransparentPieces> 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;
@@ -198,11 +232,11 @@ namespace UgcJobs {
entry["opaqueAfter"] = model.opaque.TriangleCount();
entry["vertices"] = model.opaque.positions.size() + model.transparent.positions.size();
opaquePieces.push_back(DivideByLook(model.opaque, separate));
transparentPieces.push_back(settings.combineTransparent ? UgcModel::Divide(model.transparent) : UgcModel::SplitAt(model.transparent, model.transparentBricks));
size_t shapes = transparentPieces.back().size();
transparentPieces.push_back(DivideTransparent(model, settings.combineTransparent, separate[static_cast<size_t>(UgcModel::eLook::GLITTER)]));
size_t shapes = transparentPieces.back()[0].size() + transparentPieces.back()[1].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
// Triangles per group (NiLODNode) when metal, glow or glitter 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++) {
@@ -210,7 +244,11 @@ namespace UgcJobs {
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();
for (size_t kind = 0; kind < 2; kind++) {
size_t triangles = 0;
for (const auto& piece : transparentPieces.back()[kind]) triangles += piece.TriangleCount();
if (triangles > 0) byGroup[ShapeName(settings, kind ? UgcModel::eLook::GLITTER : UgcModel::eLook::PLASTIC, true)] = triangles;
}
}
lodStats.push_back(entry);
}
@@ -218,17 +256,20 @@ namespace UgcJobs {
// 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) {
// Transparent glitter last, after the plain transparent bricks.
const auto groups = [&](size_t levels, const std::vector<LookPieces>& opaque, const std::vector<TransparentPieces>& transparent) {
std::vector<UgcFormats::NifLodGroup> out;
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);
for (size_t kind = 0; kind < UgcModel::LOOK_COUNT + 2; kind++) {
const bool isTransparent = kind >= UgcModel::LOOK_COUNT;
const bool glitter = kind == UgcModel::LOOK_COUNT + 1 || kind == static_cast<size_t>(UgcModel::eLook::GLITTER);
const auto look = glitter ? UgcModel::eLook::GLITTER : 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);
if (glitter) group.glitter = &settings.shaders.glitterParams;
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[i] : opaque[i][kind])) lod.pieces.push_back(&piece);
for (const auto& piece : (isTransparent ? transparent[i][glitter ? 1 : 0] : opaque[i][kind])) lod.pieces.push_back(&piece);
any = any || !lod.pieces.empty();
group.lods.push_back(std::move(lod));
}
@@ -242,7 +283,7 @@ namespace UgcJobs {
AddDownload(outcome.files, "model.nif", nif);
{
const std::vector<LookPieces> opaque{ DivideByLook(preview.opaque, separate) };
const std::vector<std::vector<UgcModel::Mesh>> transparent{ transparentPieces[0] };
const std::vector<TransparentPieces> transparent{ transparentPieces[0] };
outcome.files["model.noao.nif.gz"] = ZCompression::Gzip(UgcFormats::WriteLodNif("SceneNode_Model", groups(1, opaque, transparent)));
}

View File

@@ -23,20 +23,23 @@ namespace UgcJobs {
* 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").
* The shaders of the metal, glow and glitter 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, or S01_Alpha_Model when
* transparent, 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
uint32_t glitter{}; // shader_glitter: 21 LEGO-AnimUV (opaque and transparent glitter, each a group)
float glowEmissive{ 1.0f }; // glow_emissive: the glow shapes' NiMaterialProperty emissive (how much the vertex color shows unlit)
UgcGlitter::Params glitterParams; // glitter_size, glitter_density, glitter_speed
// 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
// client's Polished Metal (88), Brushed Steel (89), LEGO-Emissive (46) and LEGO-AnimUV (21) for .nifs made with
// other settings
std::map<int32_t, UgcModel::eLook> TagLooks() const;
};
@@ -81,7 +84,8 @@ namespace UgcJobs {
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
// S89_Brushed_Model, S46_Glow_Model, S21_Glitter_Model and S21_GlitterAlpha_Model (transparent glitter; 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)

View File

@@ -223,7 +223,7 @@ namespace UgcModel {
Model model;
std::set<uint32_t> missing;
const bool luToolbox = options.palette == ePalette::LU_TOOLBOX;
bool anyGlow = false, anyLook = false;
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);
@@ -283,10 +283,16 @@ namespace UgcModel {
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);
const auto look = transparent ? eLook::PLASTIC : LookOf(colorId, library.GetMaterial(colorId), options.looks);
anyLook = anyLook || look != eLook::PLASTIC;
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++) {
@@ -298,10 +304,8 @@ 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);
model.opaque.looks.push_back(look);
}
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);
}
@@ -309,6 +313,7 @@ namespace UgcModel {
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;
}
@@ -376,7 +381,7 @@ 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) {
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);

View File

@@ -37,18 +37,19 @@ namespace UgcModel {
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.
* 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 };
constexpr size_t LOOK_COUNT = 4;
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
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; }
@@ -77,11 +78,12 @@ namespace UgcModel {
/**
* 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 always plastic.
* 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)
std::map<std::string, eLook> materialTypes{ { "shinySteel", eLook::METAL }, { "brushedSteel", eLook::BRUSHED }, { "matteSteel", eLook::BRUSHED } };
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
};
@@ -99,6 +101,12 @@ namespace UgcModel {
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 };
};
/**
@@ -131,7 +139,8 @@ 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). `tagLooks`:
// the look of the opaque shapes whose multishader tag (NifFile::ShaderTag, a mapShaders id) is listed
// 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 = {});
/**

View File

@@ -461,6 +461,11 @@ namespace UgcRender {
};
const glm::vec3 toCamera = glm::normalize(dir);
const glm::vec3 halfway = glm::normalize(light + toCamera);
bool anyGlitter = false;
for (const auto* mesh : { &model.opaque, &model.transparent }) {
anyGlitter = anyGlitter || std::find(mesh->looks.begin(), mesh->looks.end(), UgcModel::eLook::GLITTER) != mesh->looks.end();
}
const auto glitterAlpha = anyGlitter ? UgcGlitter::FleckAlpha(options.glitter.flecks) : std::vector<uint8_t>{};
const auto shade = [&](const UgcModel::Mesh& mesh, bool isOpaque, uint32_t i0, uint32_t i1, uint32_t i2, float w0, float w1, float w2) {
glm::vec3 normal(0.0f, 1.0f, 0.0f);
@@ -483,8 +488,13 @@ 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);
const auto look = isOpaque && mesh.looks.size() == mesh.positions.size() ? mesh.looks[i0] : UgcModel::eLook::PLASTIC;
if (look == UgcModel::eLook::PLASTIC) {
const auto look = mesh.looks.size() == mesh.positions.size() && (isOpaque || mesh.looks[i0] == UgcModel::eLook::GLITTER) ? mesh.looks[i0] : UgcModel::eLook::PLASTIC;
if (look == UgcModel::eLook::GLITTER) {
// LEGO-AnimUV: lerp(vertex color, the texture's white, its alpha), then lit as plastic
const float fleck = UgcGlitter::Sample(glitterAlpha, UgcGlitter::Uv(position, normal, options.glitter.tile));
base = glm::vec4(glm::mix(glm::vec3(base), glm::vec3(1.0f), fleck), base.a);
}
if (look == UgcModel::eLook::PLASTIC || look == UgcModel::eLook::GLITTER) {
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));
}

View File

@@ -5,6 +5,7 @@
#include <glm/glm.hpp>
#include "UgcGlitter.h"
#include "UgcModel.h"
/**
@@ -56,6 +57,7 @@ namespace UgcRender {
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)
UgcGlitter::Params glitter; // the glitter's flecks (glitter_size, glitter_density), drawn where they are at the start
};
// The model drawn from the icon's camera, framed to fit, on a transparent background. `opaqueAo`: the opaque mesh's
@@ -63,7 +65,8 @@ namespace UgcRender {
// 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).
// steel (Polished Metal, Brushed Steel: an environment map tinted by the vertex color). GLITTER vertices (opaque or
// transparent) get the glitter texture's white flecks over their color before the light (LEGO-AnimUV), still.
Image RenderIcon(const UgcModel::Model& model, const IconOptions& options, const std::vector<float>* opaqueAo = nullptr);
struct OptimizeOptions {

View File

@@ -62,6 +62,19 @@ namespace {
return GeneralUtils::TryParse<T>(Game::config->GetValue(key)).value_or(fallback);
}
// A list of color ids setting: empty is `fallback`, "none" (or anything without ids) is no colors
std::set<uint32_t> ColorList(const std::string& key, const std::string& fallback) {
const auto value = Game::config->GetValue(key);
std::set<uint32_t> colors;
std::stringstream stream(value.empty() ? fallback : value);
std::string id;
while (std::getline(stream, id, ',')) {
std::erase_if(id, [](unsigned char c) { return std::isspace(c); });
if (const auto color = GeneralUtils::TryParse<uint32_t>(id)) colors.insert(*color);
}
return colors;
}
std::vector<uint32_t> ParseLods(const std::string& text) {
std::vector<uint32_t> lods;
std::stringstream stream(text);
@@ -104,9 +117,14 @@ namespace {
settings.shaders.brushed = std::min(Setting<uint32_t>("shader_brushed", 89), 9999u);
settings.shaders.glow = std::min(Setting<uint32_t>("shader_glow", 46), 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 } }) {
// Glitter colors in S<id>_Glitter_Model and S<id>_GlitterAlpha_Model with drifting flecks (LEGO-AnimUV)
settings.shaders.glitter = std::min(Setting<uint32_t>("shader_glitter", 21), 9999u);
settings.shaders.glitterParams.tile = std::clamp(Setting<float>("glitter_size", 1.6f), 0.1f, 100.0f);
settings.shaders.glitterParams.flecks = std::min(Setting<uint32_t>("glitter_density", 50), 2000u);
settings.shaders.glitterParams.speed = std::clamp(Setting<float>("glitter_speed", 1.0f), 0.0f, 100.0f);
// Which Materials.xml MaterialTypes are metal, brushed steel and glitter
for (const auto& [key, look] : { std::pair{ "metal_material_types", UgcModel::eLook::METAL }, std::pair{ "brushed_material_types", UgcModel::eLook::BRUSHED },
std::pair{ "glitter_material_types", UgcModel::eLook::GLITTER } }) {
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; });
@@ -117,17 +135,15 @@ namespace {
if (!type.empty() && type != "none") settings.build.looks.materialTypes[type] = look;
}
}
// LEGO color ids drawn as brushed steel whatever their Materials.xml type
{
// Empty: the default (the drum lacquered colors); none: no colors
const auto value = Game::config->GetValue("brushed_colors");
std::stringstream stream(value.empty() ? "298,300,1002,1004" : value);
std::string id;
while (std::getline(stream, id, ',')) {
std::erase_if(id, [](unsigned char c) { return std::isspace(c); });
if (const auto color = GeneralUtils::TryParse<uint32_t>(id)) settings.build.looks.colors[*color] = UgcModel::eLook::BRUSHED;
}
}
// LEGO color ids drawn as brushed steel (by default the drum lacquered colors) and as glitter (by default the
// ones LEGO's own color data files as glitter that the client's Materials.xml calls shinyPlastic) whatever their
// Materials.xml type; empty: the default, none: no colors
for (const auto color : ColorList("brushed_colors", "298,300,1002,1004")) settings.build.looks.colors[color] = UgcModel::eLook::BRUSHED;
for (const auto color : ColorList("glitter_colors", "114,117")) settings.build.looks.colors[color] = UgcModel::eLook::GLITTER;
// Satin (opal) colors: milky and less see-through in the transparent group (the client has no satin shader)
settings.build.satinColors = ColorList("satin_colors", "360,362,363,364,365,366,367,376");
settings.build.satinOpacity = std::clamp(Setting<float>("satin_opacity", 75.0f), 0.0f, 100.0f);
settings.build.satinWhiten = std::clamp(Setting<float>("satin_whiten", 20.0f), 0.0f, 100.0f);
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);
@@ -142,6 +158,8 @@ namespace {
return value.empty() ? std::nullopt : std::optional(value);
});
settings.icon.size = Setting<int32_t>("icon_size", 128);
settings.icon.glowEmissive = settings.shaders.glowEmissive;
settings.icon.glitter = settings.shaders.glitterParams;
settings.icon.ao.distance = settings.ao.distance;
settings.maxBricks = Setting<uint32_t>("max_model_bricks", 0);
return settings;