Files
DarkflameServer/dDashboardServer/static/js/ugc-viewer.js
Aaron Kimbrell e42fd45c1f 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>
2026-09-29 00:40:11 -05:00

170 lines
7.6 KiB
JavaScript

/**
* 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 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, glitterSettings, SHADER_LOOK } from '/js/scenery-core.js';
export function createNifViewer(container) {
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.textContent = '';
container.style.position = 'relative';
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1e2126);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environmentIntensity = 0.5;
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
scene.add(sun, sun.target);
const camera = new THREE.PerspectiveCamera(40, 1, 0.05, 20000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
const root = new THREE.Group();
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);
function resize() {
const width = container.clientWidth || 300, height = container.clientHeight || 300;
renderer.setSize(width, height, false);
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
const observer = new ResizeObserver(resize);
observer.observe(container);
resize();
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);
}
requestAnimationFrame(loop);
function clear() {
for (const part of parts) {
root.remove(part.mesh);
part.mesh.geometry.dispose();
part.mesh.material.dispose();
}
parts = [];
}
function applyLook() {
for (const part of parts) {
const material = part.mesh.material;
material.wireframe = wireframe;
material.vertexColors = vertexColors && part.hasColors;
material.color.copy(vertexColors && part.hasColors ? part.baseColor : grey);
material.needsUpdate = true;
}
}
function frame() {
const box = new THREE.Box3().setFromObject(root);
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
const radius = Math.max(box.getSize(new THREE.Vector3()).length() / 2, 0.1);
// From the icon's side: in front and to the side, a little above
const direction = new THREE.Vector3(Math.sin(0.93) * Math.cos(0.34), Math.sin(0.34), Math.cos(0.93) * Math.cos(0.34));
camera.position.copy(center).addScaledVector(direction, radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)) * 1.05);
camera.near = radius / 100;
camera.far = radius * 100;
camera.updateProjectionMatrix();
controls.target.copy(center);
sun.position.copy(center).add(new THREE.Vector3(0.23, 0.77, 0.6).multiplyScalar(radius * 4));
sun.target.position.copy(center);
framed = true;
}
return {
/**
* Loads a mesh; resolves {triangles, vertices, shapes}. The view keeps its angle between loads of the same model
* (LOD, version) so they can be compared; `reframe` points it at the model again.
*/
async load(url, reframe) {
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
let message = response.status === 408 ? 'Being made, try again in a moment' : 'HTTP ' + response.status;
try { message = (await response.json()).error || message; } catch (e) { /* not JSON */ }
throw new Error(message);
}
const model = parseModel(await response.arrayBuffer());
clear();
let triangles = 0, vertices = 0;
for (const mesh of mergeMeshes(model.meshes)) {
if (!mesh.vertices || !mesh.indices.length) continue;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3));
if (mesh.normals) geometry.setAttribute('normal', new THREE.BufferAttribute(mesh.normals, 3, true));
const hasColors = !!(mesh.colors && mesh.vertexColors !== 0);
if (hasColors) geometry.setAttribute('color', new THREE.BufferAttribute(linearColors(mesh.colors), 4, true));
geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
if (!mesh.normals) geometry.computeVertexNormals();
const baseColor = new THREE.Color().setRGB(mesh.diffuse[0], mesh.diffuse[1], mesh.diffuse[2], THREE.SRGBColorSpace);
// Blending only matters where something is see-through (every shape of a brick model blends)
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;
const metal = metalOf(look);
// Glow: the emissive shader's vertex color, unlit (its vertex alpha is the glow, not opacity)
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; 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', ...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;
root.add(object);
parts.push({ mesh: object, hasColors, baseColor, glitter });
triangles += mesh.indices.length / 3;
vertices += mesh.vertices;
}
applyLook();
if (reframe || !framed) frame();
return { triangles, vertices, shapes: model.meshes.length };
},
setWireframe(on) { wireframe = on; applyLook(); },
setVertexColors(on) { vertexColors = on; applyLook(); },
frame,
dispose() {
disposed = true;
observer.disconnect();
clear();
controls.dispose();
pmrem.dispose();
renderer.dispose();
renderer.domElement.remove();
}
};
}