fix(ugc): each glitter brick gets its own fleck pattern

Every glitter brick had the same flecks in the same places: the UVs were
the vertex positions projected on an axis plane, so bricks a whole tile
apart (and every brick of the same shape at the same spot in its own
model) looked identical. Each brick now has a number of its own
(UgcGlitter::BrickSeed, from the model's id and the brick's index, kept
per vertex in Mesh::brickSeeds) that turns the projection by an angle
and moves it by an offset under a tile, differently for each axis
plane. A model made again gets the same patterns; every LOD of a brick
the same one. The icon now draws the flecks on the UVs the .nif has
(Mesh::uvs read back by FromNif). New setting glitter_random (1; 0 puts
the same pattern on every brick as before). Non-glitter models are
byte-identical (hash tests unchanged).

Check in game: reprocess a model with several glitter bricks of the
same shape; the fleck patterns differ from brick to brick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 00:17:43 -05:00
parent 0a1e33d3d2
commit 63bfaf545e
11 changed files with 148 additions and 11 deletions

View File

@@ -480,6 +480,7 @@ namespace {
c.Add(Float(UGC, "glitter_size", "Glitter tile size", "The fleck texture's tile in model units (a stud is 0.8): how far apart the flecks are, the same on every brick.", "1.6", 0.1f, 100));
c.Add(Int(UGC, "glitter_density", "Glitter flecks", "Flecks in one tile of the glitter texture.", "50", 0, 2000));
c.Add(Float(UGC, "glitter_speed", "Glitter speed", "How fast the flecks drift: 1 moves them a tile in 7 s one way and 11 s the other; 0 keeps them still.", "1", 0, 100));
c.Add(Bool(UGC, "glitter_random", "Glitter placed per brick", "Each glitter brick gets its own fleck pattern (turned and moved by a number of the brick's own, the same every time the model is made); off: the same pattern on every brick.", true));
c.Add(Text(UGC, "satin_colors", "Satin colors", "Satin (opal) color ids, comma separated: they stay transparent plastic (the client has no satin shader) but are made milky and less see-through. By default LEGO's satin colors 360,362,363,364,365,366,367,376 (none: off)." + notLive, "360,362,363,364,365,366,367,376"));
c.Add(Float(UGC, "satin_opacity", "Satin opacity", "Percent: the opacity of transparent satin bricks, instead of the transparent opacity.", "75", 0, 100));
c.Add(Float(UGC, "satin_whiten", "Satin whitening", "Percent: how far satin colors go towards white.", "20", 0, 100));

View File

@@ -144,7 +144,8 @@ namespace {
out.I32(-1); // collision object
}
// `glitter`: with a UV set projected for the glitter texture (UgcGlitter::Uv)
// `glitter`: with a UV set projected for the glitter texture (UgcGlitter::Uv), placed by each vertex's brick
// (Mesh::brickSeeds) when the glitter is random
std::string TriShapeData(const UgcModel::Mesh& mesh, const UgcGlitter::Params* glitter = nullptr) {
Writer out;
const auto count = static_cast<uint16_t>(mesh.positions.size());
@@ -191,7 +192,8 @@ namespace {
}
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);
const uint32_t seed = glitter->random && v < mesh.brickSeeds.size() ? mesh.brickSeeds[v] : 0;
const auto uv = UgcGlitter::Uv(mesh.positions[v], mesh.normals[v], glitter->tile, seed);
out.Float(uv.x);
out.Float(uv.y);
}

View File

@@ -53,12 +53,33 @@ namespace UgcGlitter {
return levels;
}
glm::vec2 Uv(const glm::vec3& position, const glm::vec3& normal, float tile) {
namespace {
uint64_t SplitMix(uint64_t x) {
x += 0x9E3779B97F4A7C15ull;
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ull;
x = (x ^ (x >> 27)) * 0x94D049BB133111EBull;
return x ^ (x >> 31);
}
// 0..1 from 24 bits of a hash
float Unit(uint64_t bits) { return static_cast<float>(bits >> 40) / static_cast<float>(1ull << 24); }
}
uint32_t BrickSeed(uint64_t modelSeed, uint32_t brick) {
const auto hash = SplitMix(SplitMix(modelSeed ^ 0x676C6974746572ull) + brick);
return static_cast<uint32_t>(hash >> 32) | 1u;
}
glm::vec2 Uv(const glm::vec3& position, const glm::vec3& normal, float tile, uint32_t seed) {
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;
const int plane = a.x >= a.y && a.x >= a.z ? 0 : a.y >= a.z ? 1 : 2;
const glm::vec2 uv = (plane == 0 ? glm::vec2(position.z, position.y) : plane == 1 ? glm::vec2(position.x, position.z) : glm::vec2(position.x, position.y)) * scale;
if (seed == 0) return uv;
const auto hash = SplitMix((static_cast<uint64_t>(seed) << 2) | static_cast<uint64_t>(plane));
const float angle = Unit(hash) * 6.28318530718f;
const glm::vec2 offset(Unit(SplitMix(hash)), Unit(SplitMix(hash + 1)));
const float c = std::cos(angle), s = std::sin(angle);
return glm::vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y) + offset;
}
float Sample(const std::vector<uint8_t>& alpha, const glm::vec2& uv) {

View File

@@ -9,7 +9,8 @@
* 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.
* same density, turned and moved by a number of each brick's own (BrickSeed) so no two bricks have the same pattern,
* 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)
@@ -19,6 +20,7 @@ namespace UgcGlitter {
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
bool random{ true }; // glitter_random: each brick its own pattern (BrickSeed), else the same on every brick
// 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; }
@@ -33,8 +35,13 @@ namespace UgcGlitter {
// 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);
// A brick's number for placing its glitter (never 0), from the model's seed and the brick's index: the same for
// the brick in every LOD and every time the model is made
uint32_t BrickSeed(uint64_t modelSeed, uint32_t brick);
// A vertex's UV: its position on the axis plane its normal faces most, in tiles, turned by an angle and moved by
// an offset (under a tile) that `seed` (the brick's BrickSeed) picks for each plane; seed 0 leaves it as it is
glm::vec2 Uv(const glm::vec3& position, const glm::vec3& normal, float tile, uint32_t seed = 0);
// The texture's alpha (0..1) at `uv` (wrapping, bilinear)
float Sample(const std::vector<uint8_t>& alpha, const glm::vec2& uv);

View File

@@ -10,6 +10,7 @@
#include <glm/gtc/matrix_transform.hpp>
#include "NifFile.h"
#include "UgcGlitter.h"
#include "UgcPalette.h"
#include "tinyxml2.h"
@@ -164,6 +165,16 @@ namespace UgcModel {
if (other.looks.empty()) looks.resize(positions.size(), eLook::PLASTIC);
else looks.insert(looks.end(), other.looks.begin(), other.looks.end());
}
if (!brickSeeds.empty() || !other.brickSeeds.empty()) {
brickSeeds.resize(base, 0);
if (other.brickSeeds.empty()) brickSeeds.resize(positions.size(), 0);
else brickSeeds.insert(brickSeeds.end(), other.brickSeeds.begin(), other.brickSeeds.end());
}
if (!uvs.empty() || !other.uvs.empty()) {
uvs.resize(base, glm::vec2(0.0f));
if (other.uvs.empty()) uvs.resize(positions.size(), glm::vec2(0.0f));
else uvs.insert(uvs.end(), other.uvs.begin(), other.uvs.end());
}
indices.reserve(indices.size() + other.indices.size());
for (const auto index : other.indices) indices.push_back(base + index);
}
@@ -232,6 +243,7 @@ namespace UgcModel {
continue;
}
model.bricks++;
const auto brickSeed = UgcGlitter::BrickSeed(options.seed, brick);
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
@@ -306,6 +318,7 @@ namespace UgcModel {
mesh.colors.push_back(color);
if (&mesh == &model.opaque) model.opaque.glow.push_back(glow);
mesh.looks.push_back(look);
mesh.brickSeeds.push_back(brickSeed);
}
for (const auto i : geometry.indices) mesh.indices.push_back(base + i);
}
@@ -363,6 +376,7 @@ namespace UgcModel {
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);
if (source.uvs.size() == count * 2) mesh.uvs.emplace_back(source.uvs[v * 2], source.uvs[v * 2 + 1]);
}
mesh.indices.assign(source.indices.begin(), source.indices.end());
// Normals from the faces when the file has none
@@ -406,6 +420,8 @@ namespace UgcModel {
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]);
if (source < mesh.brickSeeds.size()) piece.brickSeeds.push_back(mesh.brickSeeds[source]);
if (source < mesh.uvs.size()) piece.uvs.push_back(mesh.uvs[source]);
}
piece.indices.push_back(it->second);
}
@@ -428,6 +444,8 @@ namespace UgcModel {
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]);
if (source < mesh.brickSeeds.size()) kept.brickSeeds.push_back(mesh.brickSeeds[source]);
if (source < mesh.uvs.size()) kept.uvs.push_back(mesh.uvs[source]);
}
kept.indices.push_back(remap[source]);
}
@@ -461,6 +479,8 @@ namespace UgcModel {
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]);
if (source < mesh.brickSeeds.size()) current.brickSeeds.push_back(mesh.brickSeeds[source]);
if (source < mesh.uvs.size()) current.uvs.push_back(mesh.uvs[source]);
}
current.indices.push_back(it->second);
}
@@ -514,6 +534,8 @@ namespace UgcModel {
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]);
if (source < mesh.brickSeeds.size()) half.brickSeeds.push_back(mesh.brickSeeds[source]);
if (source < mesh.uvs.size()) half.uvs.push_back(mesh.uvs[source]);
}
half.indices.push_back(remap[source]);
}

View File

@@ -50,6 +50,11 @@ namespace UgcModel {
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)
// Per vertex: its brick's UgcGlitter::BrickSeed, which places the brick's glitter; empty when not known (a
// mesh read from a .nif)
std::vector<uint32_t> brickSeeds;
// Per vertex: the UV set of a mesh read from a .nif (its glitter's, placed when it was made); empty otherwise
std::vector<glm::vec2> uvs;
std::vector<uint32_t> indices;
size_t TriangleCount() const { return indices.size() / 3; }

View File

@@ -384,7 +384,10 @@ namespace UgcRender {
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));
// On the mesh's own UVs (read from the .nif: each brick's pattern placed as it was made), else projected
const auto uv = mesh.uvs.size() == mesh.positions.size() ? mesh.uvs[i0] * w0 + mesh.uvs[i1] * w1 + mesh.uvs[i2] * w2 :
UgcGlitter::Uv(position, normal, options.glitter.tile);
const float fleck = UgcGlitter::Sample(glitterAlpha, uv);
base = glm::vec4(glm::mix(glm::vec3(base), glm::vec3(1.0f), fleck), base.a);
}
if (look == UgcModel::eLook::PLASTIC || look == UgcModel::eLook::GLITTER) {

View File

@@ -122,6 +122,7 @@ namespace {
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);
settings.shaders.glitterParams.random = Setting<int32_t>("glitter_random", 1) != 0;
// 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 } }) {

View File

@@ -306,6 +306,7 @@ all of its levels, so each look needs a group of its own.
| `glitter_size` | 1.6 | The glitter texture's tile, in model units (a stud is 0.8): the flecks' spacing, the same on every brick. |
| `glitter_density` | 50 | Flecks in one tile. |
| `glitter_speed` | 1 | How fast the flecks drift: a tile in U in 7 s and in V in 11 s at 1; 0 keeps them still (no controllers). |
| `glitter_random` | 1 | Each glitter brick its own fleck pattern (turned and moved by the brick); 0: the same pattern on every brick. |
| `satin_colors` | 360,362,363,364,365,366,367,376 | Satin (opal) colors, see Satin below. The default: LEGO's color data's "Satin Colors" category (the Transparent ... Opal colors). Empty: the default; `none`: off. |
| `satin_opacity` | 75 | Percent: the vertex alpha of transparent satin bricks, instead of `transparent_opacity` or the Materials.xml alpha. |
| `satin_whiten` | 20 | Percent: how far satin colors are moved towards white (in linear RGB, after the color variation). |
@@ -352,7 +353,11 @@ flecks on a brick that is otherwise lit as plastic, and moving the texture trans
What a glitter shape has, beside what plastic shapes have (white material, alpha, specular, vertex colors):
- A UV set: each vertex's position on the axis plane its normal faces most, divided by `glitter_size`
(`UgcGlitter::Uv`), so the flecks are as dense on every brick and every side.
(`UgcGlitter::Uv`), so the flecks are as dense on every brick and every side, then turned by an angle and moved by
an offset under a tile that the brick picks for each plane (`glitter_random`, on by default): each brick's number
(`UgcGlitter::BrickSeed`, from the model's id and the brick's index, kept per vertex in `Mesh::brickSeeds`), so
no two bricks have the same pattern, every LOD of a brick has its own, and a model made again gets the same. The
icon draws the flecks on the UVs the .nif has (`Mesh::uvs`, read back by `UgcModel::FromNif`).
- An `NiTexturingProperty` (one per file, shared by both glitter groups): apply mode decal (fixed function would do
what the shader does), 9 slots, the base map only: wrap S and T, trilinear, UV set 0, a texture transform
(translation 0, scale 1, Maya method, center 0.5).

View File

@@ -95,6 +95,7 @@ brushed_colors=298,300,1002,1004
# over the color and moves it. 0: off, glitter stays plastic.
# glitter_size: the fleck texture's tile in model units (a stud is 0.8); glitter_density: flecks in a tile;
# glitter_speed: 1 moves the flecks a tile in 7 s one way and 11 s the other, 0 keeps them still.
# glitter_random: 1 places each brick's flecks its own way (turned and moved by the brick), 0 the same on every brick.
# glitter_colors: LEGO color ids that are glitter whatever their type (empty: the default, 114,117, which LEGO's color
# data calls glitter; none: no colors)
shader_glitter=21
@@ -103,6 +104,7 @@ glitter_colors=114,117
glitter_size=1.6
glitter_density=50
glitter_speed=1
glitter_random=1
# Satin (opal) colors stay transparent plastic (the client has no satin shader) but are made milky:
# satin_colors: color ids (empty: the default, LEGO's satin colors 360,362,363,364,365,366,367,376; none: off),
# satin_opacity: their transparent bricks' opacity in percent (instead of transparent_opacity),

View File

@@ -1551,6 +1551,74 @@ TEST(UgcShaders, GlitterGroups) {
EXPECT_EQ(off.stats.find("groups"), std::string::npos);
}
// Each glitter brick gets its own fleck pattern: bricks a whole number of tiles apart (whose projected UVs are the same
// but for whole tiles) get different ones, the same model made again the same ones, another model others;
// glitter_random 0 puts the same pattern on every brick as before
TEST(UgcShaders, GlitterIsPlacedPerBrick) {
// Brick seeds: never 0, different bricks and models different, the same brick the same
EXPECT_NE(UgcGlitter::BrickSeed(7, 0), 0u);
EXPECT_EQ(UgcGlitter::BrickSeed(7, 3), UgcGlitter::BrickSeed(7, 3));
EXPECT_NE(UgcGlitter::BrickSeed(7, 3), UgcGlitter::BrickSeed(7, 4));
EXPECT_NE(UgcGlitter::BrickSeed(7, 3), UgcGlitter::BrickSeed(8, 3));
// Seed 0 is the plain projection; a seed turns and moves it
const glm::vec3 p(0.4f, 0.8f, 0.2f), q(1.2f, 0.8f, 0.2f);
EXPECT_EQ(UgcGlitter::Uv(p, { 0, 0, 1 }, 1.6f, 0), UgcGlitter::Uv(p, { 0, 0, 1 }, 1.6f));
const auto a = UgcGlitter::Uv(p, { 0, 0, 1 }, 1.6f, 12345), b = UgcGlitter::Uv(q, { 0, 0, 1 }, 1.6f, 12345);
EXPECT_NE(a, UgcGlitter::Uv(p, { 0, 0, 1 }, 1.6f));
EXPECT_NEAR(glm::length(b - a), 0.5f, 1e-5f); // turned and moved, not stretched: still 0.8 / 1.6 tiles apart
EXPECT_NE(UgcGlitter::Uv(p, { 0, 0, 1 }, 1.6f, 12345), UgcGlitter::Uv(p, { 0, 0, 1 }, 1.6f, 54321));
UgcBricks::BrickLibrary library(MakeRes(), 0);
library.SetMaterials({ { 5001, { 67, 84, 147, 150, "glitter" } } });
// Three transparent glitter bricks 3.2 apart (two tiles of 1.6)
const std::string lxfml = R"(<LXFML versionMajor="5"><Bricks>
<Brick><Part designID="3001" materials="5001"><Bone transformation="1,0,0,0,1,0,0,0,1,0,0,0"/></Part></Brick>
<Brick><Part designID="3001" materials="5001"><Bone transformation="1,0,0,0,1,0,0,0,1,3.2,0,0"/></Part></Brick>
<Brick><Part designID="3001" materials="5001"><Bone transformation="1,0,0,0,1,0,0,0,1,6.4,0,0"/></Part></Brick>
</Bricks></LXFML>)";
auto settings = SmallSettings();
settings.shaders.glitter = 21;
// Each glitter shape's UVs' fractions (the texture wraps), LOD 0
const auto patterns = [&](const UgcJobs::Outcome& outcome) {
std::string error;
const auto read = NifFile::Parse(*ZCompression::Gunzip(outcome.files.at("model.nif.gz")), 0, error);
EXPECT_TRUE(read) << error;
std::vector<std::vector<float>> out;
for (const auto& mesh : read->meshes) {
if (mesh.material.shaderTag != 21) continue;
std::vector<float> fractions;
for (const auto uv : mesh.uvs) fractions.push_back(std::round((uv - std::floor(uv)) * 1000.0f) / 1000.0f);
out.push_back(fractions);
}
return out;
};
const auto random = UgcJobs::ProcessModel(lxfml, library, settings, 7);
ASSERT_TRUE(random.ok) << random.error;
const auto perBrick = patterns(random);
ASSERT_EQ(perBrick.size(), 3u); // one shape per transparent brick
EXPECT_NE(perBrick[0], perBrick[1]);
EXPECT_NE(perBrick[1], perBrick[2]);
EXPECT_NE(perBrick[0], perBrick[2]);
// The same model made again: the same file; another model (seed) with the same bricks: other patterns
EXPECT_EQ(random.files.at("model.nif.checksum"), UgcJobs::ProcessModel(lxfml, library, settings, 7).files.at("model.nif.checksum"));
EXPECT_NE(patterns(UgcJobs::ProcessModel(lxfml, library, settings, 8)), perBrick);
// Off: the same pattern on every brick, as before
settings.shaders.glitterParams.random = false;
const auto off = UgcJobs::ProcessModel(lxfml, library, settings, 7);
const auto same = patterns(off);
ASSERT_EQ(same.size(), 3u);
EXPECT_EQ(same[0], same[1]);
EXPECT_EQ(same[1], same[2]);
// The icon draws the flecks where the .nif has them (its UVs, read back), so it changes with the placement
std::string error;
const auto read = NifFile::Parse(*ZCompression::Gunzip(random.files.at("model.nif.gz")), 0, error);
ASSERT_TRUE(read) << error;
const auto back = UgcModel::FromNif(*read, settings.shaders.TagLooks());
EXPECT_EQ(back.transparent.uvs.size(), back.transparent.positions.size());
EXPECT_NE(random.files.at("icon.png"), off.files.at("icon.png"));
}
// Glitter in the icon: the texture's flecks over the color before the light, where they are at the start
TEST(UgcShaders, IconsDrawGlitterFlecks) {
UgcModel::Model model;