fix(ugc): glitter sparkles that move on placed models

Why the glitter never moved: player models (LOT 14) are wrapped in
weeblewobble.kfm (RenderComponentWrapper 9845), so the client makes them
an LWOSkinnedRenderComponent, whose Run (0x00d6d3d0) updates the scene
graph (and so any NiTextureTransformController) only while animation is
enabled, and LWOModelBehaviorComponent::EnableAnimation (0x00be2740)
turns it off for modelType 2, which every placed property model is. The
root flags 0x102 added earlier are only read by the base render
component. Nothing in a placed model's .nif can move.

What does move: shader classes set globals in their own per-frame Run.
Distortion Directional (Ocean) (mapShaders 79, Run 0x010b90c0) slides
its texture layers by fixed shares of a tile a second, as the game's own
pond ripples (S79__pond_ripplesShape). Glitter bricks now get a sparkle
group, S79_GlitterSparkle_Model: their triangles lifted 0.005 off the
brick, vertex colors white tinted by the brick, UVs placed per brick,
alpha tested (ShaderCommon's alpha test phase, GREATEREQUAL 127), with a
stored texture of flat sparkles at alpha 230: one layer's sparkle alone
averages under the test, two meeting pass, so sparkles flash and go out
as the layers cross. The flecks stay (LEGO-AnimUV, now without the
controllers and flags that never ran). The icon and the dashboard's 3D
view leave the sparkles out.

New settings: shader_glitter_sparkle (79, 0 off), glitter_sparkle_size,
glitter_sparkle_amount, glitter_sparkle_tint, glitter_sparkle_brightness;
glitter_speed is now how fast sparkles flash (the sparkle tile). Only
glitter output changes; non-glitter models are byte-identical.

Check in game: reprocess a property with glitter models, then look at
them from a few angles and distances, on each graphics quality:
- sparkles flash on and off all over the glitter bricks, continuously
- no flickering fight between the sparkles and the brick surface
- transparent glitter bricks still see-through, flecks still visible
- nothing drawn where there is no glitter brick; icons unchanged
  apart from the flecks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 00:27:55 -05:00
parent 63bfaf545e
commit fb7850fb9f
19 changed files with 562 additions and 248 deletions

View File

@@ -4,6 +4,19 @@
#include <cmath>
namespace UgcGlitter {
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); }
// The side of a square texture's alpha
int Side(const std::vector<uint8_t>& alpha) { return static_cast<int>(std::lround(std::sqrt(static_cast<double>(alpha.size())))); }
}
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);
@@ -36,55 +49,89 @@ namespace UgcGlitter {
return out;
}
std::vector<std::vector<uint8_t>> Mipmaps(const std::vector<uint8_t>& alpha) {
float Params::SparkleTile() const {
return 75.0f * std::max(sparkleSize, 0.001f) * std::max(speed, 0.01f);
}
int Params::SparkleTextureSize() const {
// A sparkle 3 pixels wide: side = 3 * tile / size (225 at speed 1)
const float wanted = 3.0f * SparkleTile() / std::max(sparkleSize, 0.001f);
int side = 128;
while (side < 1024 && static_cast<float>(side) < wanted) side *= 2;
return side;
}
std::vector<uint8_t> SparkleAlpha(const Params& params) {
const int N = params.SparkleTextureSize();
std::vector<uint8_t> alpha(static_cast<size_t>(N) * N, 0);
const float radius = std::max(params.sparkleSize / params.SparkleTile() * static_cast<float>(N) * 0.5f, 0.75f);
const float share = std::clamp(params.sparkleAmount, 0.0f, 100.0f) / 100.0f;
const auto count = static_cast<uint32_t>(std::lround(share * static_cast<float>(N) * static_cast<float>(N) / (3.14159265f * radius * radius)));
uint64_t state = 0x737061726B6C6500ull;
const auto next = [&state] { return Unit(SplitMix(state++)); };
const int reach = static_cast<int>(std::ceil(radius + 0.5f));
for (uint32_t i = 0; i < count; i++) {
const float cx = next() * N, cy = next() * N;
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;
// Flat, with a pixel's worth of edge
const float cover = std::clamp(radius + 0.5f - std::sqrt(ddx * ddx + ddy * ddy), 0.0f, 1.0f);
if (cover <= 0.0f) continue;
auto& value = alpha[static_cast<size_t>(((y % N) + N) % N) * N + ((x % N) + N) % N];
value = std::max(value, static_cast<uint8_t>(std::lround(cover * SPARKLE_ALPHA)));
}
}
}
return alpha;
}
std::vector<std::vector<uint8_t>> Mipmaps(const std::vector<uint8_t>& alpha, int keepPeaks) {
std::vector<std::vector<uint8_t>> levels{ alpha };
for (int size = TEXTURE_SIZE / 2; size >= 1; size /= 2) {
for (int size = Side(alpha) / 2, level = 0; size >= 1; size /= 2, level++) {
const auto& above = levels.back();
const int from = size * 2;
std::vector<uint8_t> level(static_cast<size_t>(size) * size);
std::vector<uint8_t> next(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);
next[static_cast<size_t>(y) * size + x] = static_cast<uint8_t>(level < keepPeaks ? std::max({ at(0, 0), at(1, 0), at(0, 1), at(1, 1) }) :
(at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1) + 2) / 4);
}
}
levels.push_back(std::move(level));
levels.push_back(std::move(next));
}
return levels;
}
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) {
glm::vec2 Uv(const glm::vec3& position, const glm::vec3& normal, float tile, uint32_t seed, eLayer layer) {
const auto a = glm::abs(normal);
const float scale = 1.0f / std::max(tile, 1e-3f);
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 auto hash = SplitMix(((static_cast<uint64_t>(seed) << 2) | static_cast<uint64_t>(plane)) ^ (static_cast<uint64_t>(layer) << 40));
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;
}
glm::vec4 SparkleColor(const glm::vec4& brickColor, const Params& params) {
const float tint = std::clamp(params.sparkleTint, 0.0f, 100.0f) / 100.0f;
const float brightness = std::clamp(params.sparkleBrightness, 0.0f, 100.0f) / 100.0f;
return glm::vec4(glm::clamp(glm::mix(glm::vec3(1.0f), glm::vec3(brickColor), tint) * brightness, 0.0f, 1.0f), 1.0f);
}
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 int N = Side(alpha);
if (N == 0 || 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;

View File

@@ -6,43 +6,81 @@
#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, 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.
* The glitter the UGC server gives glitter colors (docs/UgcServer.md, "Glitter"). Pure.
*
* Flecks: 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). They stay still: a placed player model is never
* updated after it loads (LWOSkinnedRenderComponent::Run with animation off for modelType 2), so texture controllers in
* its .nif never run.
*
* Sparkles: a second shape over each glitter brick drawn by the client's Distortion Directional (Ocean) shader, whose
* texture layers the client moves every frame whatever the object does (the shader's Run sets
* g_vDirectionalMotionLayer1..3). Its texture has flat sparkles at an alpha that one layer's alone keeps under the
* alpha test, so a sparkle shows only where two moving layers' sparkles meet: points that flash and go out.
*
* Both are placed on each brick by UVs projected from the model's own coordinates, turned and moved by a number of the
* brick's own (BrickSeed), so no two bricks have the same pattern and a model made again has the same one.
*/
namespace UgcGlitter {
// The texture's side in pixels (a power of two, mipmapped down to 1)
// The fleck 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)
// Flecks (LEGO-AnimUV)
float tile{ 1.6f }; // glitter_size: the fleck 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
// Sparkles (Distortion Directional)
float sparkleSize{ 0.1f }; // glitter_sparkle_size: a sparkle's diameter in model units
float sparkleAmount{ 5.0f }; // glitter_sparkle_amount: percent of each moving layer covered by sparkles
float speed{ 1.0f }; // glitter_speed: how fast sparkles flash and go out (1: about half a second)
float sparkleTint{ 30.0f }; // glitter_sparkle_tint: percent, how far sparkles take their brick's color
float sparkleBrightness{ 100.0f }; // glitter_sparkle_brightness: percent, the sparkles' vertex color
// 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; }
// The sparkle texture's side in model units. The client moves its layers a fixed share of a tile a second (a
// tile in 24, 48 and 72 s), so the tile sets how fast they cross: 75 sparkle sizes times the speed.
float SparkleTile() const;
// The sparkle texture's side in pixels: the power of two (128 to 1024) that makes a sparkle 3 pixels wide
int SparkleTextureSize() const;
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.
// The sparkles' alpha in their texture. The alpha test of the client's alpha test phase keeps what reaches 127
// (ShaderCommon__SetupPhaseRenderStates: GREATEREQUAL 0x7f); the Directional shader averages 2 layers or 3 (by the
// graphics settings), so one sparkle alone is 115 or 77 and two meeting are 230 or 153
constexpr uint8_t SPARKLE_ALPHA = 230;
// How far the sparkle shapes stand off their brick, along its normals (model units): in front of its surface, so
// the brick (drawn after them when it is transparent) doesn't cover them and they don't fight it for the depth
constexpr float SPARKLE_LIFT = 0.005f;
// The fleck 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);
// The sparkle texture's alpha (SparkleTextureSize() squared): flat discs of sparkleSize at SPARKLE_ALPHA covering
// sparkleAmount percent of it, at the same places every time, tiling. Its color is white.
std::vector<uint8_t> SparkleAlpha(const Params& params);
// A square texture's mipmaps' alpha, from its own size down to 1: each the mean of 2x2 of the one before, or for
// the first `keepPeaks` the brightest of them (so sparkles keep their alpha at the next few distances)
std::vector<std::vector<uint8_t>> Mipmaps(const std::vector<uint8_t>& alpha, int keepPeaks = 0);
// 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);
// Which texture a UV set is for: a brick's sparkles are placed apart from its flecks
enum class eLayer : uint8_t { FLECKS = 0, SPARKLES };
// The texture's alpha (0..1) at `uv` (wrapping, bilinear)
// 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 and layer; seed 0 leaves it
// as it is
glm::vec2 Uv(const glm::vec3& position, const glm::vec3& normal, float tile, uint32_t seed = 0, eLayer layer = eLayer::FLECKS);
// A sparkle's vertex color: white taking `sparkleTint` percent of its brick's color (sRGB), at `sparkleBrightness`,
// alpha 1
glm::vec4 SparkleColor(const glm::vec4& brickColor, const Params& params);
// A square texture's alpha (0..1) at `uv` (wrapping, bilinear)
float Sample(const std::vector<uint8_t>& alpha, const glm::vec2& uv);
}

View File

@@ -363,9 +363,10 @@ namespace UgcModel {
return ranges;
}
Model FromNif(const NifFile::Model& nif, const std::map<int32_t, eLook>& tagLooks) {
Model FromNif(const NifFile::Model& nif, const std::map<int32_t, eLook>& tagLooks, const std::set<int32_t>& overlayTags) {
Model model;
for (const auto& source : nif.meshes) {
if (source.material.alphaTest && overlayTags.contains(source.material.shaderTag)) continue;
Mesh mesh;
const size_t count = source.positions.size() / 3;
const bool vertexColors = source.material.vertexColorMode == 2 && source.colors.size() == count * 4;

View File

@@ -145,8 +145,9 @@ namespace UgcModel {
// 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 = {});
// transparent ones when it is GLITTER. `overlayTags`: alpha tested shapes with these tags are left out (the UGC
// server's glitter sparkles, drawn over the glitter bricks)
Model FromNif(const NifFile::Model& nif, const std::map<int32_t, eLook>& tagLooks = {}, const std::set<int32_t>& overlayTags = {});
/**
* The mesh's triangles by look ([eLook] -> its triangles; a triangle's look is its first vertex's), the looks not