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

@@ -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 } from '/js/scenery-core.js';
import { parseModel, mergeMeshes, linearColors, nearPlaneFor, addGlitter } from '/js/scenery-core.js';
const GEOMETRY_MAGIC = 0x42473031; // "10GB"
const MAX_PARALLEL_FETCHES = 6;
@@ -174,11 +174,13 @@ async function loadGeneratedModel(url) {
// ---- Viewer ----
const materialCache = new Map();
// The glitter colours' moving flecks (window.LDD_GLITTER, the UGC server's glitter settings), updated each frame
const glitterMaterials = [];
function material(id) {
if (!materialCache.has(id)) {
const c = (window.LDD_MATERIALS || {})[id] || [160, 160, 160, 255];
const transparent = c[3] < 255;
materialCache.set(id, new THREE.MeshPhysicalMaterial({
const created = new THREE.MeshPhysicalMaterial({
color: new THREE.Color().setRGB(c[0] / 255, c[1] / 255, c[2] / 255, THREE.SRGBColorSpace),
roughness: 0.28,
metalness: 0,
@@ -187,7 +189,14 @@ function material(id) {
transparent,
opacity: c[3] / 255,
depthWrite: !transparent
}));
});
const glitter = window.LDD_GLITTER;
if (glitter && (glitter.colors || []).includes(Number(id))) {
// Moving as the game moves its fleck texture: a tile in U in 7 s and in V in 11 s at speed 1
const speed = glitter.speed || 0;
glitterMaterials.push(addGlitter(created, { coordinates: 'position', tile: glitter.tile || 1.6, flecks: glitter.flecks || 50, scroll: [speed / 7, speed / 11] }));
}
materialCache.set(id, created);
}
return materialCache.get(id);
}
@@ -749,6 +758,7 @@ export function createViewer(container, { onProgress, onSelect, onTick, brickUrl
const dt = Math.min((now - lastTime) / 1000, 0.1);
lastTime = now;
if (onTick) onTick(dt);
for (const glitter of glitterMaterials) glitter.update(now / 1000);
updateBubbles(now);
controls.update();
// The near plane follows how far out the camera is (depth precision for the scenery far away), but never

View File

@@ -67,7 +67,50 @@ export function shaderOf(manifest, asset, mesh) {
}
// NifFile::eShaderLook bits
export const SHADER_LOOK = { UNLIT: 1, NO_TEXTURE: 2, NO_VERTEX_COLORS: 4, MATERIAL_COLOR: 8, TWO_LAYERS_BLENDED: 16, TWO_LAYERS_ADDED: 32, REFLECTIVE: 64, BRUSHED: 128, EMISSIVE: 256 };
export const SHADER_LOOK = { UNLIT: 1, NO_TEXTURE: 2, NO_VERTEX_COLORS: 4, MATERIAL_COLOR: 8, TWO_LAYERS_BLENDED: 16, TWO_LAYERS_ADDED: 32, REFLECTIVE: 64, BRUSHED: 128, EMISSIVE: 256,
GLITTER: 512 };
/**
* 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.
*/
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)) } };
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.replace('#include <begin_vertex>', '#include <begin_vertex>\n' +
(byPosition ? `vec4 glitterAt = vec4(transformed, 1.0);
vec3 glitterNormal = objectNormal;
#ifdef USE_INSTANCING
glitterAt = instanceMatrix * glitterAt;
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') + `
float glitterHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
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));
}
` + 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');
};
material.customProgramCacheKey = () => 'glitter-' + coordinates;
material.needsUpdate = true;
return { update(seconds) { uniforms.glitterTime.value = seconds; } };
}
/**
* How a mesh is drawn under the game's shaders, when the manifest has the zone's lighting: {lit, texture,
@@ -112,7 +155,7 @@ export function mergeMeshes(meshes) {
for (const mesh of meshes) {
if (!mesh.vertices || !mesh.indices.length) continue;
const key = JSON.stringify([mesh.texture, mesh.diffuse, mesh.emissive, mesh.alpha, mesh.blend, mesh.test, mesh.doubleSided,
mesh.vertexColors, mesh.clampU, mesh.clampV, !!mesh.normals, !!mesh.uvs, !!mesh.colors, mesh.shaderTag, mesh.darkTexture, !!mesh.uvs2]);
mesh.vertexColors, mesh.clampU, mesh.clampV, !!mesh.normals, !!mesh.uvs, !!mesh.colors, mesh.shaderTag, mesh.darkTexture, !!mesh.uvs2, mesh.look, mesh.uvScroll]);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(mesh);
}

View File

@@ -2,12 +2,13 @@
* 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.
* 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).
*/
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, SHADER_LOOK } from '/js/scenery-core.js';
import { parseModel, mergeMeshes, linearColors, metalOf, addGlitter, SHADER_LOOK } from '/js/scenery-core.js';
export function createNifViewer(container) {
const renderer = new THREE.WebGLRenderer({ antialias: true });
@@ -31,6 +32,7 @@ export function createNifViewer(container) {
scene.add(root);
let parts = [];
const clock = new THREE.Clock();
let wireframe = false, vertexColors = true, disposed = false, framed = false;
const grey = new THREE.Color(0xbbbbbb);
@@ -49,6 +51,8 @@ export function createNifViewer(container) {
function loop() {
if (disposed) return;
controls.update();
const seconds = clock.getElapsedTime();
for (const part of parts) if (part.glitter) part.glitter.update(seconds);
renderer.render(scene, camera);
requestAnimationFrame(loop);
}
@@ -125,10 +129,16 @@ export function createNifViewer(container) {
? 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
let glitter = null;
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] });
}
const object = new THREE.Mesh(geometry, material);
if (seeThrough) object.renderOrder = 1;
root.add(object);
parts.push({ mesh: object, hasColors, baseColor });
parts.push({ mesh: object, hasColors, baseColor, glitter });
triangles += mesh.indices.length / 3;
vertices += mesh.vertices;
}