feat(dashboard): glitter sparkles and flecks in the UGC 3D views

The UGC page's model view now draws the glitter sparkle shapes (look
SPARKLE) flashing as the client's Distortion Directional shader makes
them: two layers of sparkles sliding at the client's rates (a tile in
24 s at three quarters the scale, a tile in 48 s), shown only where both
have one. Flecks are drawn as flat flakes of the fleck size and varied
brightness (addGlitter in scenery-core.js), no longer drifting (the game
never moves them). The LXFML views (the UGC page's second view, the
property and zone views) draw both on the glitter colors from
window.LDD_GLITTER, which now carries every glitter setting and is no
longer cached, so changing a glitter setting on the settings page
previews on any model's LXFML view without making it again.

Check in the dashboard (not the game): open a glitter model on the UGC
page; both views show still flecks and sparkles flashing on the glitter
bricks only; change glitter_sparkle_amount / glitter_fleck_size in the
settings and reopen the model: the LXFML view follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 00:39:35 -05:00
parent aab161038e
commit e42fd45c1f
5 changed files with 128 additions and 35 deletions

View File

@@ -457,7 +457,7 @@ void RegisterClientAssetRoutes() {
Route(eHTTPMethod::GET, "/api/bricks/materials.js", 0,
"The brick colours (MatID -> [r, g, b, a]) from Materials.xml in the client's res/brickdb.zip, as a script setting "
"window.LDD_MATERIALS for the 3D viewers, and window.LDD_GLITTER: the colours the UGC server makes glitter with its settings "
"(shader_glitter on: glitter_material_types and glitter_colors) and the glitter's glitter_size and glitter_density. "
"(shader_glitter on: glitter_material_types and glitter_colors) and the glitter's settings (flecks, and sparkles when shader_glitter_sparkle is on). "
"The colours are read once; an empty table when the client's brick database can't be read",
[](HTTPReply& reply, const HTTPContext&) {
// Read on first use (thread-safe static init); the client only changes with a restart
@@ -505,11 +505,17 @@ void RegisterClientAssetRoutes() {
{ "flecks", GeneralUtils::TryParse<uint32_t>(UgcRoutes::Setting("glitter_density").value_or("")).value_or(80) },
{ "fleckSize", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_fleck_size").value_or("")).value_or(0.05f) },
{ "fleckOpacity", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_fleck_opacity").value_or("")).value_or(80.0f) },
{ "speed", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_speed").value_or("")).value_or(1.0f) } };
{ "speed", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_speed").value_or("")).value_or(1.0f) },
{ "sparkles", GeneralUtils::TryParse<uint32_t>(UgcRoutes::Setting("shader_glitter_sparkle").value_or("")).value_or(79) != 0 },
{ "sparkleSize", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_sparkle_size").value_or("")).value_or(0.1f) },
{ "sparkleAmount", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_sparkle_amount").value_or("")).value_or(5.0f) },
{ "sparkleTint", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_sparkle_tint").value_or("")).value_or(30.0f) },
{ "sparkleBrightness", GeneralUtils::TryParse<float>(UgcRoutes::Setting("glitter_sparkle_brightness").value_or("")).value_or(100.0f) } };
reply.status = eHTTPStatusCode::OK;
reply.message = colours.script + "window.LDD_GLITTER = " + settings.dump() + ";\n";
reply.contentType = eContentType::TEXT_JAVASCRIPT;
reply.headers.push_back("Cache-Control: private, max-age=3600");
// Not kept: the glitter settings change from the settings page, and the preview should follow
reply.headers.push_back("Cache-Control: private, no-cache");
});
Route(eHTTPMethod::GET, "/api/icon/:lot", 0, "PNG icon for an item LOT (requires client_location and ImageMagick)",

View File

@@ -14,7 +14,7 @@
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { parseModel, mergeMeshes, linearColors, nearPlaneFor, addGlitter } from '/js/scenery-core.js';
import { parseModel, mergeMeshes, linearColors, nearPlaneFor, addGlitter, glitterSettings } from '/js/scenery-core.js';
const GEOMETRY_MAGIC = 0x42473031; // "10GB"
const MAX_PARALLEL_FETCHES = 6;
@@ -174,7 +174,8 @@ async function loadGeneratedModel(url) {
// ---- Viewer ----
const materialCache = new Map();
// The glitter colours' flecks (window.LDD_GLITTER, the UGC server's glitter settings)
// The glitter colours' flecks and sparkles (window.LDD_GLITTER, the UGC server's current glitter settings), the
// sparkles moved each frame
const glitterMaterials = [];
function material(id) {
if (!materialCache.has(id)) {
@@ -192,8 +193,13 @@ function material(id) {
});
const glitter = window.LDD_GLITTER;
if (glitter && (glitter.colors || []).includes(Number(id))) {
// Still, as the game draws them on a placed model
glitterMaterials.push(addGlitter(created, { coordinates: 'position', tile: glitter.tile || 1.6, flecks: glitter.flecks || 50 }));
// The flecks still and the sparkles flashing, as the game draws them on a placed model; the sparkles white
// taking the tint of the brick's color (UgcGlitter::SparkleColor)
const settings = glitterSettings();
const tint = Math.min(Math.max(settings.sparkleTint / 100, 0), 1), bright = Math.min(Math.max(settings.sparkleBrightness / 100, 0), 1);
const sparkleColor = [0, 1, 2].map((i) => Math.min(1, ((1 - tint) + tint * c[i] / 255) * bright));
const linear = new THREE.Color().setRGB(sparkleColor[0], sparkleColor[1], sparkleColor[2], THREE.SRGBColorSpace);
glitterMaterials.push(addGlitter(created, { coordinates: 'position', ...settings, sparkleColor: [linear.r, linear.g, linear.b] }));
}
materialCache.set(id, created);
}

View File

@@ -59,18 +59,41 @@ export const SHADER_LOOK = { UNLIT: 1, NO_TEXTURE: 2, NO_VERTEX_COLORS: 4, MATER
GLITTER: 512, SPARKLE: 1024 };
/**
* Glitter for a three.js material (the UGC server's glitter colors, UgcGlitter): white flecks over the color before
* the light, as the game's LEGO-AnimUV shader lays its fleck texture over the vertex color. The flecks are drawn in
* the shader from a hash of cells of `coordinates` ('uv': the mesh's UVs, one tile of the texture each; 'position':
* a box projection of the object's position, `tile` units a tile, for meshes without UVs), `flecks` a tile, moving
* `scroll` tiles a second. Returns {update(seconds)} to animate it.
* The UGC server's glitter (UgcGlitter) for a three.js material, drawn in the shader from hashes of cells:
*
* Flecks ({flecks}): flat flakes over the color before the light, as the game's LEGO-AnimUV shader lays its fleck
* texture over the vertex color: `flecks` a tile, `fleckSize` model units across (0.7 to 1.3 of it), each as bright as
* its facet catches the light (0.3 to 1 of `fleckOpacity` percent, mostly dim). On `coordinates` 'uv' (the mesh's
* 'glitterUv' attribute, in tiles) or 'position' (a box projection of the object's position, `tile` units a tile).
*
* Sparkles ({sparkles}): what the game's Distortion Directional shader does with the sparkle texture: two layers of
* sparkles (`sparkleAmount` percent covered, `sparkleSize` across) sliding at the client's rates (a tile in 24 s at
* three quarters the scale, and in 48 s), drawn only where both have one (the alpha test), in `sparkleColor`. On
* 'glitterSparkleUv' (in sparkle tiles) or the projected position (`sparkleTile` units a tile). With `sparklesOnly`
* the rest of the surface is discarded and the sparkles keep the material's color (the made model's sparkle shapes,
* whose vertex colors are the sparkles').
*
* Returns {update(seconds)} to move the sparkles.
*/
export function addGlitter(material, { coordinates = 'uv', tile = 1.6, flecks = 50, scroll = [0, 0] } = {}) {
const uniforms = { glitterTime: { value: 0 }, glitterScroll: { value: scroll }, glitterTile: { value: tile }, glitterCells: { value: Math.max(1, Math.sqrt(flecks)) } };
export function addGlitter(material, { coordinates = 'uv', tile = 1.6, flecks = 80, fleckSize = 0.05, fleckOpacity = 80, sparkles = false,
sparkleTile = 7.5, sparkleSize = 0.1, sparkleAmount = 5, sparkleColor = [1, 1, 1], sparklesOnly = false } = {}) {
const fleckCells = Math.max(1, Math.sqrt(flecks));
// A sparkle's radius in sparkle tiles, and cells of one sparkle each covering `sparkleAmount` percent
const sparkleRadius = sparkleSize / Math.max(sparkleTile, 1e-3) / 2;
const sparkleShare = Math.min(Math.max(sparkleAmount / 100, 0), 0.5);
const sparkleCells = sparkleShare > 0 ? Math.sqrt(sparkleShare / (Math.PI * sparkleRadius * sparkleRadius)) : 1;
const uniforms = {
glitterTime: { value: 0 }, glitterTile: { value: tile }, glitterCells: { value: fleckCells },
glitterFleckRadius: { value: fleckSize / Math.max(tile, 1e-3) / 2 * fleckCells }, glitterOpacity: { value: Math.min(Math.max(fleckOpacity / 100, 0), 1) },
glitterFlecksOn: { value: sparklesOnly ? 0 : 1 },
sparkleOn: { value: sparkles && sparkleShare > 0 ? 1 : 0 }, sparkleTileSize: { value: sparkleTile }, sparkleCells: { value: sparkleCells },
sparkleCellRadius: { value: sparkleRadius * sparkleCells }, sparkleColor: { value: new Float32Array(sparkleColor) }, sparklesOnly: { value: sparklesOnly ? 1 : 0 }
};
material.onBeforeCompile = (shader) => {
Object.assign(shader.uniforms, uniforms);
const byPosition = coordinates === 'position';
shader.vertexShader = 'varying vec3 vGlitterPosition;\nvarying vec3 vGlitterNormal;\n' + (byPosition ? '' : 'attribute vec2 glitterUv;\nvarying vec2 vGlitterUv;\n') +
shader.vertexShader = 'varying vec3 vGlitterPosition;\nvarying vec3 vGlitterNormal;\n' +
(byPosition ? '' : 'attribute vec2 glitterUv;\nvarying vec2 vGlitterUv;\nattribute vec2 glitterSparkleUv;\nvarying vec2 vGlitterSparkleUv;\n') +
shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\n' +
(byPosition ? `vec4 glitterAt = vec4(transformed, 1.0);
vec3 glitterNormal = objectNormal;
@@ -80,26 +103,75 @@ glitterNormal = mat3(instanceMatrix) * glitterNormal;
#endif
vGlitterPosition = glitterAt.xyz;
vGlitterNormal = glitterNormal;
` : 'vGlitterUv = glitterUv;\n'));
shader.fragmentShader = 'uniform float glitterTime;\nuniform vec2 glitterScroll;\nuniform float glitterTile;\nuniform float glitterCells;\nvarying vec3 vGlitterPosition;\nvarying vec3 vGlitterNormal;\n' +
(byPosition ? '' : 'varying vec2 vGlitterUv;\n') + `
` : 'vGlitterUv = glitterUv;\nvGlitterSparkleUv = glitterSparkleUv;\n'));
shader.fragmentShader = `uniform float glitterTime;
uniform float glitterTile;
uniform float glitterCells;
uniform float glitterFleckRadius;
uniform float glitterOpacity;
uniform float glitterFlecksOn;
uniform float sparkleOn;
uniform float sparkleTileSize;
uniform float sparkleCells;
uniform float sparkleCellRadius;
uniform vec3 sparkleColor;
uniform float sparklesOnly;
varying vec3 vGlitterPosition;
varying vec3 vGlitterNormal;
` + (byPosition ? '' : 'varying vec2 vGlitterUv;\nvarying vec2 vGlitterSparkleUv;\n') + `
float glitterHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
// A flat disc of radius r (in cells) at a random place in each cell, its edge a pixel wide
float glitterDisc(vec2 uv, float cells, float r, float salt) {
vec2 at = uv * cells;
vec2 cell = floor(at);
vec2 centre = r + (1.0 - 2.0 * r) * vec2(glitterHash(cell + salt), glitterHash(cell + salt + 17.0));
float d = length(fract(at) - centre);
float edge = max(fwidth(d), 1e-4);
return clamp((r - d) / edge + 0.5, 0.0, 1.0);
}
float glitterFleck(vec2 uv) {
vec2 cell = floor(uv * glitterCells);
vec2 centre = vec2(glitterHash(cell), glitterHash(cell + 17.0));
float d = length(fract(uv * glitterCells) - centre) * 128.0 / glitterCells;
return (0.65 + 0.35 * glitterHash(cell + 41.0)) * (1.0 - smoothstep(0.0, 1.7, d));
float size = 0.7 + 0.6 * glitterHash(cell + 29.0);
float facet = glitterHash(cell + 41.0);
return glitterOpacity * (0.3 + 0.7 * facet * facet) * glitterDisc(uv, glitterCells, min(glitterFleckRadius * size, 0.45), 0.0);
}
// Two layers sliding as Distortion Directional's (2-layer technique): uv * 0.75 + a tile in 24 s, uv - 0.3 + a tile in 48 s
float glitterSparkle(vec2 uv) {
float r = min(sparkleCellRadius, 0.45);
float a = glitterDisc(uv * 0.75 + vec2(glitterTime / 24.0, 0.0), sparkleCells, r, 3.0);
float b = glitterDisc(uv - 0.3 + vec2(glitterTime / 48.0, 0.0), sparkleCells, r, 7.0);
return step(0.996, a + b);
}
` + shader.fragmentShader.replace('#include <color_fragment>', '#include <color_fragment>\n' + (byPosition ? `
vec3 glitterN = abs(vGlitterNormal);
vec2 glitterUv = (glitterN.x >= glitterN.y && glitterN.x >= glitterN.z ? vGlitterPosition.zy : glitterN.y >= glitterN.z ? vGlitterPosition.xz : vGlitterPosition.xy) / glitterTile;
` : 'vec2 glitterUv = vGlitterUv;\n') + 'diffuseColor.rgb = mix(diffuseColor.rgb, vec3(1.0), glitterFleck(glitterUv + glitterScroll * glitterTime));\n');
vec2 glitterPlane = glitterN.x >= glitterN.y && glitterN.x >= glitterN.z ? vGlitterPosition.zy : glitterN.y >= glitterN.z ? vGlitterPosition.xz : vGlitterPosition.xy;
vec2 glitterUv = glitterPlane / glitterTile;
vec2 glitterSparkleUv = glitterPlane / sparkleTileSize;
` : 'vec2 glitterUv = vGlitterUv;\nvec2 glitterSparkleUv = vGlitterSparkleUv;\n') + `
if (glitterFlecksOn > 0.5) diffuseColor.rgb = mix(diffuseColor.rgb, vec3(1.0), glitterFleck(glitterUv));
if (sparkleOn > 0.5) {
float glitterSparkles = glitterSparkle(glitterSparkleUv);
if (sparklesOnly > 0.5 && glitterSparkles < 0.5) discard;
if (sparklesOnly < 0.5) diffuseColor = mix(diffuseColor, vec4(sparkleColor, 1.0), glitterSparkles);
}
`);
};
material.customProgramCacheKey = () => 'glitter-' + coordinates;
material.needsUpdate = true;
return { update(seconds) { uniforms.glitterTime.value = seconds; } };
}
// The UGC server's glitter settings for addGlitter (window.LDD_GLITTER), its defaults without them
export function glitterSettings() {
const g = window.LDD_GLITTER || {};
const speed = g.speed > 0 ? g.speed : 1, sparkleSize = g.sparkleSize > 0 ? g.sparkleSize : 0.1;
return {
tile: g.tile || 1.6, flecks: g.flecks ?? 80, fleckSize: g.fleckSize || 0.05, fleckOpacity: g.fleckOpacity ?? 80,
sparkles: !!g.sparkles, sparkleSize, sparkleAmount: g.sparkleAmount ?? 5, sparkleTile: 75 * sparkleSize * speed,
sparkleTint: g.sparkleTint ?? 30, sparkleBrightness: g.sparkleBrightness ?? 100
};
}
// NifFile::eTechniqueFlag bits (what a technique does besides its family and eShaderLook bits)
export const TECHNIQUE = {
UV_ANIM: 1, DOUBLE_SIDED: 2, BLEND: 4, ALPHA_TEST: 8, ADDITIVE: 16, NO_AMBIENT: 32, GLOW: 64, IGNORE_VERTEX_ALPHA: 128,

View File

@@ -2,13 +2,14 @@
* A 3D view of a .nif the UGC server made, from /api/ugc/mesh/:id (the dashboard converts it with NifFile::Encode,
* as it does the scenery's models), with wireframe and vertex color switches and triangle counts. The metal and glow
* groups (the UGC server's shader settings; each mesh's "look" is its shader's eShaderLook bits) are drawn as metal
* reflecting the view's environment and as unlit glow, the glitter groups with moving white flecks (their UVs and
* uvScroll, as the game moves its fleck texture).
* reflecting the view's environment and as unlit glow, the glitter groups with their flecks on their UVs and the
* glitter sparkles flashing as the game's Distortion Directional shader makes them (addGlitter), by the UGC server's
* current glitter settings (window.LDD_GLITTER, when the page has /api/bricks/materials.js).
*/
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { parseModel, mergeMeshes, linearColors, metalOf, addGlitter, SHADER_LOOK } from '/js/scenery-core.js';
import { parseModel, mergeMeshes, linearColors, metalOf, addGlitter, glitterSettings, SHADER_LOOK } from '/js/scenery-core.js';
export function createNifViewer(container) {
const renderer = new THREE.WebGLRenderer({ antialias: true });
@@ -123,19 +124,23 @@ export function createNifViewer(container) {
let seeThrough = !!mesh.blend && mesh.alpha < 0.99;
if (mesh.blend && hasColors) for (let i = 3; i < mesh.colors.length && !seeThrough; i += 4) seeThrough = mesh.colors[i] < 250;
const look = mesh.look || 0;
// The glitter sparkles (drawn over the glitter bricks) aren't drawn here
if (look & SHADER_LOOK.SPARKLE) continue;
const metal = metalOf(look);
// Glow: the emissive shader's vertex color, unlit (its vertex alpha is the glow, not opacity)
const material = look & SHADER_LOOK.EMISSIVE
const sparkles = !!(look & SHADER_LOOK.SPARKLE);
const material = sparkles ? new THREE.MeshLambertMaterial({ color: baseColor.clone(), vertexColors: hasColors })
: look & SHADER_LOOK.EMISSIVE
? new THREE.MeshBasicMaterial({ color: baseColor.clone(), vertexColors: hasColors })
: new THREE.MeshStandardMaterial({ color: baseColor.clone(), vertexColors: hasColors, transparent: seeThrough,
roughness: metal === 'polished' ? 0.18 : metal === 'brushed' ? 0.45 : 0.6, metalness: metal ? 1 : 0 });
// Glitter: the fleck texture's UVs, one tile of it each, moving as the game moves it
// Glitter: the fleck texture's UVs, one tile of it each; the sparkle shapes' UVs, one sparkle tile each
let glitter = null;
const settings = glitterSettings();
if (look & SHADER_LOOK.GLITTER && mesh.uvs) {
geometry.setAttribute('glitterUv', new THREE.BufferAttribute(mesh.uvs, 2));
glitter = addGlitter(material, { coordinates: 'uv', scroll: mesh.uvScroll || [0, 0] });
glitter = addGlitter(material, { coordinates: 'uv', ...settings, sparkles: false });
} else if (sparkles && mesh.uvs) {
geometry.setAttribute('glitterSparkleUv', new THREE.BufferAttribute(mesh.uvs, 2));
glitter = addGlitter(material, { coordinates: 'uv', ...settings, sparkles: true, sparklesOnly: true });
}
const object = new THREE.Mesh(geometry, material);
if (seeThrough) object.renderOrder = 1;

View File

@@ -434,10 +434,14 @@ and transparent) again:
The icon draws the flecks where they are (the same texture and the .nif's UVs, before the light; `glitter_size` and
`glitter_density`), opaque and transparent, and leaves the sparkles out (`Shaders::OverlayTags`: alpha tested shapes
tagged `shader_glitter_sparkle` or 79). The UGC page's 3D view marks glitter meshes (`/api/ugc/mesh`: look
`GLITTER` 512, a mesh with a stored texture in a group tagged `shader_glitter` or 21) and draws flecks from their
UVs, and marks the sparkles (look `SPARKLE` 1024); the property and zone views, which draw bricks from the LXFML, draw
flecks on the colors in `window.LDD_GLITTER` (`/api/bricks/materials.js`: the glitter colors by the current settings)
from their positions.
`GLITTER` 512, a mesh with a stored texture in a group tagged `shader_glitter` or 21) and draws flecks on their UVs,
and marks the sparkles (look `SPARKLE` 1024, alpha tested and tagged `shader_glitter_sparkle` or 79), which it draws
flashing on theirs as the client does (`addGlitter` in `static/js/scenery-core.js`: two layers of sparkles in cells,
one at three quarters the scale sliding a tile in 24 s, the other a tile in 48 s, shown where both have one). The
LXFML views (the UGC page's second view, the property and zone views) draw flecks and sparkles on the glitter colors
from their positions, by `window.LDD_GLITTER` (`/api/bricks/materials.js`: the glitter colors and every glitter
setting as they are now, not cached): a live preview of the settings without making the model again (a brick's own
placement, `glitter_random`, is only in the made model).
#### Satin