Files
DarkflameServer/dDashboardServer/static/js/scenery.js
Aaron Kimbrell 2d1ef0e370 feat: web dashboard and playground work
The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch:
accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy
reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live
events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved
captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes
they need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:33:10 -05:00

383 lines
16 KiB
JavaScript

/**
* A zone's scenery for the 3D views (ES module, three.js): every scene object's model and the sky, the way the game
* client draws them, from the server's scenery manifest (/api/properties/:id/scenery, /api/world3d/:zone/scenery).
* The flairs' manifest (/api/world3d/:zone/flairs) has the same form plus a tint per object and a shorter draw distance.
*
* Models load nearest first around a focus point (the camera's target), a few at a time, up to the detail level's
* draw distance and memory budget. Each model's objects are drawn as InstancedMeshes, one per mesh part and square
* cell of the map, so three.js can skip cells outside the view and cells past the draw distance are hidden. The sky
* follows the camera, far away, behind everything.
*/
import * as THREE from 'three';
import { parseModel, mergeMeshes, parseDds, decodeDxt, completeChain, linearColors, groupObjects, cellsOf } from '/js/scenery-core.js';
// Per detail level (the property view's 0 high, 1 medium, 2 low): the model LOD, how far objects are drawn, the
// largest texture side and a memory budget for geometry and textures
export const DETAIL = [
{ lod: 0, distance: 1400, texture: 512, budget: 384 << 20 },
{ lod: 1, distance: 800, texture: 256, budget: 192 << 20 },
{ lod: 2, distance: 450, texture: 128, budget: 96 << 20 }
];
const CELL = 128;
const HIDDEN_COLOR = 0xff4fd8;
const PARALLEL = 4;
const UPDATE_SECONDS = 0.5;
/**
* scene, camera, renderer: the view's; urls: {manifest, mesh(zone, asset, lod), texture(zone, asset, slot, lod)};
* focus(): where to load around (defaults to the camera); onProgress(loaded, wanted).
*/
export function createScenery({ scene, camera, renderer, urls, focus, onProgress }) {
const root = new THREE.Group();
root.name = 'scenery';
scene.add(root);
const sky = new THREE.Group();
sky.name = 'sky';
scene.add(sky);
const extensions = {
s3tc: renderer.extensions.has('WEBGL_compressed_texture_s3tc'),
s3tcSrgb: renderer.extensions.has('WEBGL_compressed_texture_s3tc_srgb')
};
let manifest = null;
let byAsset = new Map(); // asset -> instances
let nearest = new Map(); // asset -> distance of its nearest instance to the last focus
let detail = DETAIL[2];
let enabled = true;
let showHidden = false;
let skyOn = true; // also draw the objects the game doesn't (manifest objects.hidden)
const assets = new Map(); // asset -> {state: 'queued'|'loading'|'done'|'failed', cells: [{group, center, radius}], bytes}
const textures = new Map(); // "lod/path" -> Promise<Texture|null>
let used = 0; // bytes of geometry and textures on the GPU
let active = 0;
let generation = 0; // bumped on clear, so late responses are dropped
let lastUpdate = 0;
const focusPoint = new THREE.Vector3();
function report() {
if (!onProgress) return;
let done = 0, wanted = 0;
for (const entry of assets.values()) {
wanted++;
if (entry.state === 'done' || entry.state === 'failed') done++;
}
onProgress(done, wanted);
}
async function fetchBuffer(url) {
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) throw new Error(response.status + ' ' + url);
return response.arrayBuffer();
}
// A DDS as a texture: compressed on GPUs that take S3TC (sRGB too), else decoded to RGBA here
function makeTexture(buffer, maxSize) {
const dds = parseDds(buffer, maxSize);
if (!dds) return null;
let texture;
if (dds.format !== 'RGBA' && extensions.s3tc && extensions.s3tcSrgb && dds.width % 4 === 0 && dds.height % 4 === 0) {
const format = dds.format === 'DXT1' ? THREE.RGBA_S3TC_DXT1_Format : dds.format === 'DXT3' ? THREE.RGBA_S3TC_DXT3_Format : THREE.RGBA_S3TC_DXT5_Format;
const levels = completeChain(dds.levels) ? dds.levels : [dds.levels[0]];
texture = new THREE.CompressedTexture(levels.map((l) => ({ data: l.data, width: l.width, height: l.height })), dds.width, dds.height, format);
texture.minFilter = levels.length > 1 ? THREE.LinearMipmapLinearFilter : THREE.LinearFilter;
texture.userData.bytes = levels.reduce((sum, l) => sum + l.data.byteLength, 0);
} else {
const level = dds.levels[0];
const rgba = dds.format === 'RGBA' ? level.data : decodeDxt(dds.format, level.width, level.height, level.data);
texture = new THREE.DataTexture(rgba, level.width, level.height, THREE.RGBAFormat);
texture.generateMipmaps = true;
texture.minFilter = THREE.LinearMipmapLinearFilter;
texture.userData.bytes = rgba.byteLength * 4 / 3;
}
// DXT1's one-bit alpha is for cut-outs (alpha tested); DXT3/5 and 32-bit files carry real transparency
texture.userData.alpha = dds.format === 'DXT3' || dds.format === 'DXT5' || (dds.format === 'RGBA' && dds.alpha);
texture.magFilter = THREE.LinearFilter;
texture.colorSpace = THREE.SRGBColorSpace;
// DDS rows run top down and the game's UVs start at the top, so no flip
texture.flipY = false;
texture.anisotropy = 4;
texture.needsUpdate = true;
return texture;
}
function loadTexture(asset, slot, path, lod) {
// Textures stored inside a .nif are named by their block ("#12"), so only unique within their model
const key = lod + '/' + (path.startsWith('#') ? asset + path : path);
if (!textures.has(key)) {
const size = detail.texture;
textures.set(key, fetchBuffer(urls.texture(manifest.zone, asset, slot, lod)).then((buffer) => {
const texture = makeTexture(buffer, size);
if (texture) used += texture.userData.bytes;
return texture;
}).catch(() => null));
}
return textures.get(key);
}
function geometryOf(mesh) {
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));
if (mesh.uvs) geometry.setAttribute('uv', new THREE.BufferAttribute(mesh.uvs, 2));
if (mesh.colors && mesh.vertexColors !== 0) geometry.setAttribute('color', new THREE.BufferAttribute(linearColors(mesh.colors), 4, true));
geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
if (!mesh.normals) geometry.computeVertexNormals();
geometry.computeBoundingSphere();
return geometry;
}
function materialOf(mesh, map, forSky) {
// Nearly everything in the game's files has alpha blending switched on; it only shows where something is see-
// through: the material, a vertex or the texture. Blended meshes still write depth, as Gamebryo's default does.
let vertexAlpha = false;
if (mesh.colors && mesh.vertexColors !== 0) for (let i = 3; i < mesh.colors.length && !vertexAlpha; i += 4) vertexAlpha = mesh.colors[i] < 250;
const seeThrough = mesh.blend && (mesh.alpha < 0.99 || vertexAlpha || !!(map && map.userData.alpha));
const options = {
color: new THREE.Color().setRGB(mesh.diffuse[0], mesh.diffuse[1], mesh.diffuse[2], THREE.SRGBColorSpace),
vertexColors: !!(mesh.colors && mesh.vertexColors !== 0),
transparent: seeThrough,
opacity: mesh.alpha,
alphaTest: mesh.test >= 0 ? Math.max(mesh.test / 255, 0.01) : 0,
side: mesh.doubleSided ? THREE.DoubleSide : THREE.FrontSide,
map: map || null
};
if (forSky) return new THREE.MeshBasicMaterial({ ...options, depthWrite: false, fog: false });
const material = new THREE.MeshStandardMaterial({ ...options, roughness: 0.85, metalness: 0 });
material.emissive.setRGB(mesh.emissive[0], mesh.emissive[1], mesh.emissive[2], THREE.SRGBColorSpace);
return material;
}
async function buildParts(asset, lod, forSky) {
const buffer = await fetchBuffer(urls.mesh(manifest.zone, asset, lod));
const model = parseModel(buffer);
const parts = [];
// The sky's layers keep their order; everything else has its look-alike pieces joined
for (const mesh of forSky ? model.meshes : mergeMeshes(model.meshes)) {
if (!mesh.vertices || !mesh.indices.length) continue;
let map = null;
if (mesh.texture >= 0 && mesh.uv) {
const texture = await loadTexture(asset, mesh.texture, model.header.textures[mesh.texture], lod);
if (texture) {
map = texture.clone(); // shares the image; wrapping differs per mesh
map.wrapS = mesh.clampU ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping;
map.wrapT = mesh.clampV ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping;
map.needsUpdate = true;
}
}
const geometry = geometryOf(mesh);
parts.push({ geometry, material: materialOf(mesh, map, forSky) });
}
const min = model.header.min, max = model.header.max;
const radius = Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2;
const center = new THREE.Vector3((min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2);
return { parts, radius, center };
}
async function loadAsset(asset) {
const entry = assets.get(asset);
const mine = generation;
entry.state = 'loading';
active++;
try {
const { parts, radius, center } = await buildParts(asset, detail.lod, false);
if (mine !== generation) { parts.forEach((p) => { p.geometry.dispose(); p.material.dispose(); }); return; }
const instances = byAsset.get(asset) || [];
const matrix = new THREE.Matrix4(), position = new THREE.Vector3(), rotation = new THREE.Quaternion(), scale = new THREE.Vector3(), tint = new THREE.Color();
// Objects the game doesn't draw (volumes, triggers) get their own cells in a see-through colour, shown on request
const cells = [];
for (const hidden of [false, true]) {
for (const [, list] of cellsOf(instances.filter((i) => !!i.hidden === hidden), CELL)) cells.push({ hidden, list });
}
for (const { hidden, list: cellInstances } of cells) {
const group = new THREE.Group();
const cellCenter = new THREE.Vector3();
let maxScale = 0;
for (const part of parts) {
if (hidden && !part.ghost) part.ghost = new THREE.MeshBasicMaterial({ color: HIDDEN_COLOR, transparent: true, opacity: 0.35, depthWrite: false, side: THREE.DoubleSide });
const mesh = new THREE.InstancedMesh(part.geometry, hidden ? part.ghost : part.material, cellInstances.length);
cellInstances.forEach((instance, i) => {
position.set(instance.x, instance.y, instance.z);
rotation.set(instance.qx, instance.qy, instance.qz, instance.qw);
scale.setScalar(instance.scale || 1);
mesh.setMatrixAt(i, matrix.compose(position, rotation, scale));
if (instance.color) {
const k = manifest.colorScale || 1 / 255;
mesh.setColorAt(i, tint.setRGB(instance.color[0] * k, instance.color[1] * k, instance.color[2] * k));
}
});
mesh.instanceMatrix.needsUpdate = true;
mesh.computeBoundingSphere();
// Scenery takes the models' shadows but casts none: a shadow pass over the zone costs more than it shows
mesh.castShadow = false;
mesh.receiveShadow = true;
group.add(mesh);
}
for (const instance of cellInstances) {
cellCenter.add(position.set(instance.x, instance.y, instance.z));
maxScale = Math.max(maxScale, instance.scale || 1);
}
cellCenter.divideScalar(cellInstances.length).add(center);
root.add(group);
// How far past the cell's centre its objects reach
let reach = 0;
for (const instance of cellInstances) reach = Math.max(reach, position.set(instance.x, instance.y, instance.z).distanceTo(cellCenter));
entry.cells.push({ group, center: cellCenter, radius: reach + radius * maxScale, hidden });
}
entry.parts = parts;
entry.bytes = parts.reduce((sum, p) => sum + p.geometry.attributes.position.array.byteLength * 2 + p.geometry.index.array.byteLength, 0);
used += entry.bytes;
entry.state = 'done';
updateVisibility();
} catch (error) {
entry.state = 'failed';
} finally {
active--;
report();
pump();
}
}
async function loadSky() {
if (!manifest || manifest.sky < 0) return;
const mine = generation;
try {
const { parts } = await buildParts(manifest.sky, 0, true);
if (mine !== generation) return;
parts.forEach((part, i) => {
const mesh = new THREE.Mesh(part.geometry, part.material);
// Its layers are drawn in the file's order, as the game does (they're all at the same distance)
mesh.renderOrder = -1000 + i;
mesh.frustumCulled = false;
sky.add(mesh);
});
} catch (error) {
// No sky: the plain background stays
}
}
function pump() {
if (!enabled || !manifest) return;
const queued = [...assets.entries()].filter(([, e]) => e.state === 'queued').sort((a, b) => nearest.get(a[0]) - nearest.get(b[0]));
for (const [asset] of queued) {
if (active >= PARALLEL) break;
if (used > detail.budget) break;
loadAsset(asset);
}
}
// Queue every model with an object within the draw distance of the focus
function want() {
if (!manifest) return;
const at = focus ? focus(focusPoint) || camera.position : camera.position;
for (const [asset, instances] of byAsset) {
let best = Infinity;
for (const i of instances) if (showHidden || !i.hidden) best = Math.min(best, Math.hypot(i.x - at.x, i.z - at.z));
nearest.set(asset, best);
if (best <= drawDistance() && !assets.has(asset)) assets.set(asset, { state: 'queued', cells: [], bytes: 0 });
}
report();
pump();
}
// The detail level's draw distance, or the manifest's own when shorter (flairs)
function drawDistance() {
return manifest && manifest.distance ? Math.min(detail.distance, manifest.distance) : detail.distance;
}
function updateVisibility() {
const eye = camera.position;
for (const entry of assets.values()) {
for (const cell of entry.cells) cell.group.visible = enabled && (!cell.hidden || showHidden) && eye.distanceTo(cell.center) - cell.radius < drawDistance();
}
}
function clear() {
generation++;
for (const entry of assets.values()) {
for (const cell of entry.cells) {
root.remove(cell.group);
cell.group.children.forEach((mesh) => mesh.dispose());
}
for (const part of entry.parts || []) {
if (part.ghost) part.ghost.dispose();
part.geometry.dispose();
if (part.material.map) part.material.map.dispose();
part.material.dispose();
}
}
assets.clear();
for (const promise of textures.values()) promise.then((t) => t && t.dispose());
textures.clear();
sky.children.slice().forEach((mesh) => { sky.remove(mesh); mesh.geometry.dispose(); mesh.material.dispose(); });
used = 0;
report();
}
return {
/** Load a manifest (or reload it at another detail level: 0 high, 1 medium, 2 low). */
async load(url = urls.manifest, level = 2) {
clear();
detail = DETAIL[Math.max(0, Math.min(DETAIL.length - 1, level))];
if (!manifest || manifest.url !== url) {
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) { manifest = null; return false; }
manifest = await response.json();
manifest.url = url;
byAsset = groupObjects(manifest.objects);
if (manifest.sky >= 0) byAsset.delete(manifest.sky);
}
loadSky();
want();
return true;
},
setDetail(level) {
const next = DETAIL[Math.max(0, Math.min(DETAIL.length - 1, level))];
if (next === detail || !manifest) return;
this.load(manifest.url, level);
},
/** Also draw the objects the game doesn't draw (trigger and blocking volumes), see-through. */
setShowHidden(on) {
showHidden = !!on;
want();
updateVisibility();
},
/** Show or hide the sky (it also goes with the whole scenery). */
setSky(on) {
skyOn = !!on;
sky.visible = enabled && skyOn;
},
setEnabled(on) {
enabled = on;
root.visible = on;
sky.visible = on && skyOn;
if (on) want();
},
/** Call every frame: the sky follows the camera; every half second, nearby models load and far cells hide. */
update(dt = 0) {
if (!enabled) return;
// The sky sits just inside the far plane, around the camera
const far = Math.max(camera.far, drawDistance() * 1.6);
if (camera.far < far) { camera.far = far; camera.updateProjectionMatrix(); }
sky.position.copy(camera.position);
sky.scale.setScalar(camera.far * 0.8);
lastUpdate += dt;
if (lastUpdate < UPDATE_SECONDS) return;
lastUpdate = 0;
want();
updateVisibility();
},
/** How many objects the loaded manifest places. */
count() { return manifest ? manifest.objects.asset.length : 0; },
stats() {
let cells = 0, drawn = 0;
for (const entry of assets.values()) for (const cell of entry.cells) { cells++; if (cell.group.visible) drawn++; }
return { assets: assets.size, loaded: [...assets.values()].filter((e) => e.state === 'done').length, cells, drawn, megabytes: Math.round(used / 1048576) };
},
dispose() {
clear();
scene.remove(root);
scene.remove(sky);
}
};
}