Files
DarkflameServer/dDashboardServer/static/js/scenery.js
Aaron Kimbrell 25e223ce19 feat(dashboard): metal and glow looks in the UGC and zone 3D views
NifFile::ShaderLookFor knows Polished Metal (98), Brushed Steel (99) and
LEGO-Emissive (53). The UGC mesh route sends each mesh's look (from its
multishader tag), and the UGC 3D view draws metal as reflective and glow
unlit. The zone views draw LEGO-Emissive objects going to their vertex
color by its alpha, as the shader does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:31:18 -05:00

650 lines
30 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, textureAlphaMode, gameLook, decodeSceneMap, sceneAt, loadedScenes } 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, onScenes }) {
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 requests = new AbortController(); // aborted on clear: requests for what's no longer wanted stop
let lastUpdate = 0;
const focusPoint = new THREE.Vector3();
// Scenes: 'all' draws every scene's objects; 'game' the ones the game keeps loaded around the focus (the scene
// under it, the scenes connected to it and the global scene); 'manual' the ones picked (setManualScenes)
let sceneMode = 'all';
let manualScenes = new Set();
let sceneMap = null;
let focusScene = null; // the scene under the focus, once known
let shownScenes = null; // Set of scene ids drawn, null: all
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);
}
// A model or texture URL with the manifest's conversion format, so a browser's week-long cache of models
// converted the old way isn't used once the server converts them anew
function versioned(url) {
return manifest && manifest.format ? url + (url.includes('?') ? '&' : '?') + 'v=' + manifest.format : url;
}
async function fetchBuffer(url) {
const response = await fetch(url, { credentials: 'same-origin', signal: requests.signal });
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, mine = generation;
textures.set(key, fetchBuffer(versioned(urls.texture(manifest.zone, asset, slot, lod))).then((buffer) => {
const texture = makeTexture(buffer, size);
// Cleared meanwhile: the texture was already let go of (clear disposes what it finds)
if (texture && mine === generation) used += texture.userData.bytes;
return texture;
}).catch(() => null));
}
return textures.get(key);
}
// Whether a mesh's vertex colors are drawn: as its game look says, else as NiVertexColorProperty does
const usesVertexColors = (mesh, look) => (look ? look.vertexColors : !!(mesh.colors && mesh.vertexColors !== 0));
function geometryOf(mesh, look) {
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 (usesVertexColors(mesh, look)) 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;
}
// The game's LEGO shaders lay the texture over the vertex colors by its alpha instead of letting it show through
const DECAL_FRAGMENT = `
#if defined( USE_COLOR_ALPHA )
diffuseColor *= vColor;
#elif defined( USE_COLOR )
diffuseColor.rgb *= vColor;
#endif
#ifdef USE_MAP
vec4 sampledDiffuseColor = texture2D( map, vMapUv );
#if defined( USE_COLOR ) || defined( USE_COLOR_ALPHA )
diffuseColor.rgb = mix( diffuseColor.rgb, sampledDiffuseColor.rgb, sampledDiffuseColor.a );
#else
diffuseColor.rgb *= sampledDiffuseColor.rgb;
#endif
#endif
`;
// ... or leave its alpha out altogether (LEGO items, terrain meshes)
const OPAQUE_MAP_FRAGMENT = `
#ifdef USE_MAP
diffuseColor.rgb *= texture2D( map, vMapUv ).rgb;
#endif
`;
// Two layer shaders: the dark texture (its own UV set) under the base one by the vertex alpha, or the two added
const TWO_LAYERS_BLENDED_FRAGMENT = `
#ifdef USE_MAP
#ifdef USE_COLOR_ALPHA
float layerMix = vColor.a;
#else
float layerMix = 1.0;
#endif
diffuseColor.rgb *= mix( texture2D( darkMap, vUvDark ).rgb, texture2D( map, vMapUv ).rgb, layerMix );
#endif
`;
const TWO_LAYERS_ADDED_FRAGMENT = `
#ifdef USE_MAP
diffuseColor *= texture2D( map, vMapUv ) * layerWeights.x + texture2D( darkMap, vUvDark ) * layerWeights.y;
#endif
`;
// The texture alpha mode's change to a fragment shader
function textureAlphaPatch(shader, mode) {
if (mode === 'decal') {
shader.fragmentShader = shader.fragmentShader.replace('#include <map_fragment>', '').replace('#include <color_fragment>', DECAL_FRAGMENT);
} else if (mode === 'ignored') {
shader.fragmentShader = shader.fragmentShader.replace('#include <map_fragment>', OPAQUE_MAP_FRAGMENT);
}
}
function useTextureAlpha(material, mode) {
if (mode === 'opacity' || !material.map) return material;
material.onBeforeCompile = (shader) => textureAlphaPatch(shader, mode);
material.customProgramCacheKey = () => 'textureAlpha:' + mode;
return material;
}
// The zone's lights as the game's shaders get them (manifest.lighting), shared by every lit material
const gameLights = {
gameLightColor: { value: new THREE.Vector3(1, 1, 1) }, gameAmbient: { value: new THREE.Vector3() }, gameLightVec: { value: new THREE.Vector3(0, 1, 0) },
gameLightOn: { value: 0 } // 1 once a manifest brought the zone's lighting (the terrain uses its own light until then)
};
function setGameLights(lighting) {
gameLights.gameLightOn.value = lighting ? 1 : 0;
if (!lighting) return;
gameLights.gameLightColor.value.fromArray(lighting.light);
gameLights.gameAmbient.value.fromArray(lighting.ambient);
gameLights.gameLightVec.value.fromArray(lighting.lightVec);
lightBlend = null;
}
// A blend from the lights now to `lighting` over a second or two, as the client blends between scenes' lighting
const BLEND_SECONDS = 1.5;
let lightBlend = null;
function blendLightsTo(lighting) {
if (!lighting) return;
lightBlend = {
from: { light: gameLights.gameLightColor.value.clone(), ambient: gameLights.gameAmbient.value.clone(), lightVec: gameLights.gameLightVec.value.clone() },
to: { light: new THREE.Vector3().fromArray(lighting.light), ambient: new THREE.Vector3().fromArray(lighting.ambient), lightVec: new THREE.Vector3().fromArray(lighting.lightVec) },
t: 0
};
}
function stepLightBlend(dt) {
if (!lightBlend) return;
lightBlend.t = Math.min(1, lightBlend.t + dt / BLEND_SECONDS);
const { from, to, t } = lightBlend;
gameLights.gameLightColor.value.lerpVectors(from.light, to.light, t);
gameLights.gameAmbient.value.lerpVectors(from.ambient, to.ambient, t);
gameLights.gameLightVec.value.lerpVectors(from.lightVec, to.lightVec, t).normalize();
if (t >= 1) lightBlend = null;
}
/**
* Lighting per vertex as BasicShaders.fx and LEGOPPLighting.fx do it: sun * max(0, N.L) + ambient in the game's
* (sRGB) color space, which the vertex shader's color output clamps to 1, then made linear for three.js.
*/
const GAME_LIGHT_VERTEX = `#include <begin_vertex>
vec3 gameNormal = normal;
#ifdef USE_INSTANCING
gameNormal = mat3( instanceMatrix ) * gameNormal;
#endif
gameNormal = normalize( mat3( modelMatrix ) * gameNormal );
vGameLight = pow( clamp( gameLightColor * max( 0.0, dot( gameNormal, gameLightVec ) ) + gameAmbient, 0.0, 1.0 ), vec3( 2.2 ) );`;
// LEGO-Emissive: the lit color goes to the vertex color by the vertex alpha times the material's emissive red
const EMISSIVE_FRAGMENT = `
#ifdef USE_COLOR_ALPHA
outgoingLight = mix( outgoingLight, vColor.rgb, clamp( vColor.a * emissiveMix, 0.0, 1.0 ) );
#endif
#include <opaque_fragment>`;
/**
* A material that draws a mesh the way its game shader does (gameLook): unlit by the view's own lights and tone
* mapping, the zone's sun and ambient light per vertex when the shader is lit, the material's color only when the
* shader reads it. Metal is drawn lit like the rest (the game adds a reflection of its own textures); glowing
* meshes go to their vertex color as the emissive shader does.
*/
function gameMaterial(options, mesh, alphaMode, look, darkMap = null) {
const material = new THREE.MeshBasicMaterial({
...options,
color: look.material ? options.color : new THREE.Color(1, 1, 1)
});
material.toneMapped = false;
const layers = darkMap ? look.layers : null;
// TwoLayersAdded_PS: base * material diffuse red + dark * material diffuse green (their animations)
const weights = new THREE.Vector2(mesh.diffuse[0], mesh.diffuse[1]);
const emissive = !!look.emissive;
material.onBeforeCompile = (shader) => {
if (emissive) {
shader.uniforms.emissiveMix = { value: mesh.emissive[0] };
shader.fragmentShader = 'uniform float emissiveMix;\n' + shader.fragmentShader.replace('#include <opaque_fragment>', EMISSIVE_FRAGMENT);
}
if (layers) {
shader.uniforms.darkMap = { value: darkMap };
shader.uniforms.layerWeights = { value: weights };
shader.vertexShader = 'attribute vec2 uvDark;\nvarying vec2 vUvDark;\n' +
shader.vertexShader.replace('#include <uv_vertex>', '#include <uv_vertex>\n\tvUvDark = uvDark;');
shader.fragmentShader = 'uniform sampler2D darkMap;\nuniform vec2 layerWeights;\nvarying vec2 vUvDark;\n' + shader.fragmentShader
.replace('#include <map_fragment>', layers === 'blended' ? TWO_LAYERS_BLENDED_FRAGMENT : TWO_LAYERS_ADDED_FRAGMENT)
.replace('#include <color_fragment>', layers === 'blended' ? '#ifdef USE_COLOR_ALPHA\n\tdiffuseColor.rgb *= vColor.rgb;\n#endif' : '#include <color_fragment>');
} else if (options.map) {
textureAlphaPatch(shader, alphaMode);
}
if (!look.lit) return;
Object.assign(shader.uniforms, gameLights);
shader.vertexShader = 'uniform vec3 gameLightColor;\nuniform vec3 gameAmbient;\nuniform vec3 gameLightVec;\nvarying vec3 vGameLight;\n' +
shader.vertexShader.replace('#include <begin_vertex>', GAME_LIGHT_VERTEX);
shader.fragmentShader = 'varying vec3 vGameLight;\n' +
shader.fragmentShader.replace('#include <aomap_fragment>', '#include <aomap_fragment>\n\treflectedLight.indirectDiffuse *= vGameLight;');
};
material.customProgramCacheKey = () => 'game:' + (options.map ? alphaMode : '') + ':' + look.lit + ':' + layers + ':' + emissive;
return material;
}
function materialOf(mesh, map, forSky, alphaMode = 'opacity', look = null, darkMap = null) {
// 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 (only when the object's shader uses the texture's alpha as
// opacity). Blended meshes still write depth, as Gamebryo's default does.
const vertexColors = usesVertexColors(mesh, look);
let vertexAlpha = false;
// A two layer blend reads the vertex alpha as the mix of its textures, not as opacity
const layersBlended = !!(darkMap && look.layers === 'blended');
// ... and the emissive shader as how much the vertex color glows
if (vertexColors && !layersBlended && !(look && look.emissive)) for (let i = 3; i < mesh.colors.length && !vertexAlpha; i += 4) vertexAlpha = mesh.colors[i] < 250;
const textureAlpha = alphaMode === 'opacity' && !!(map && map.userData.alpha);
// The game's shaders take alpha from the vertex colors and texture only; NiMaterialProperty's is for fixed function
const materialAlpha = look && !look.material ? 1 : mesh.alpha;
const seeThrough = mesh.blend && (materialAlpha < 0.99 || vertexAlpha || textureAlpha);
const options = {
color: new THREE.Color().setRGB(mesh.diffuse[0], mesh.diffuse[1], mesh.diffuse[2], THREE.SRGBColorSpace),
vertexColors,
transparent: seeThrough,
opacity: materialAlpha,
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 useTextureAlpha(new THREE.MeshBasicMaterial({ ...options, depthWrite: false, fog: false }), alphaMode);
if (look) return gameMaterial(options, mesh, alphaMode, look, darkMap);
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 useTextureAlpha(material, alphaMode);
}
async function buildParts(asset, lod, forSky) {
const buffer = await fetchBuffer(versioned(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;
// The sky keeps its own unlit look
const look = forSky ? null : gameLook(manifest, asset, mesh);
const textureOf = async (slot, clampU, clampV) => {
const texture = await loadTexture(asset, slot, model.header.textures[slot], lod);
if (!texture) return null;
const map = texture.clone(); // shares the image; wrapping differs per mesh
map.wrapS = clampU ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping;
map.wrapT = clampV ? THREE.ClampToEdgeWrapping : THREE.RepeatWrapping;
map.needsUpdate = true;
return map;
};
const map = mesh.texture >= 0 && mesh.uv && (!look || look.texture) ? await textureOf(mesh.texture, mesh.clampU, mesh.clampV) : null;
// A two layer shader's second texture, on its own UV set
const darkMap = look && look.layers && map && mesh.darkTexture >= 0 && mesh.uvs2 ? await textureOf(mesh.darkTexture, false, false) : null;
const geometry = geometryOf(mesh, look);
if (darkMap) geometry.setAttribute('uvDark', new THREE.BufferAttribute(mesh.uvs2, 2));
parts.push({ geometry, material: materialOf(mesh, map, forSky, textureAlphaMode(manifest, asset, mesh), look, darkMap) });
}
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(); if (p.material.map) p.material.map.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 = [];
// A cell holds one scene's objects, so scenes can be shown and hidden like the game streams them
const byScene = new Map();
for (const instance of instances) {
const key = instance.scene === null || instance.scene === undefined ? -1 : instance.scene;
if (!byScene.has(key)) byScene.set(key, []);
byScene.get(key).push(instance);
}
for (const [sceneId, sceneInstances] of byScene) {
for (const hidden of [false, true]) {
for (const [, list] of cellsOf(sceneInstances.filter((i) => !!i.hidden === hidden), CELL)) cells.push({ hidden, list, scene: sceneId });
}
}
for (const { hidden, list: cellInstances, scene: cellScene } 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);
mesh.userData.asset = asset; // which of the manifest's models it is, for picking
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, scene: cellScene });
}
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) && sceneShown(i.scene)) 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;
}
// Objects of no known scene (older manifests) are always drawn
function sceneShown(id) {
return !shownScenes || id === null || id === undefined || id < 0 || shownScenes.has(id);
}
// The scenes to draw and the lighting, from the mode and the scene under the focus; tells onScenes when they change
function updateScenes(force = false) {
if (!manifest) return;
const at = focus ? focus(focusPoint) || camera.position : camera.position;
const under = sceneMap ? sceneAt(sceneMap, at.x, at.z) : null;
let shown = null;
if (sceneMode === 'game' && manifest.scenes) shown = loadedScenes(manifest.scenes, under === null ? 0 : under);
else if (sceneMode === 'manual') shown = new Set(manualScenes);
const same = (a, b) => (a === b) || (a && b && a.size === b.size && [...a].every((v) => b.has(v)));
if (!force && under === focusScene && same(shown, shownScenes)) return;
focusScene = under;
shownScenes = shown;
// The game blends to the lighting of the scene the player walks into
const sceneEntry = manifest.scenes && under !== null ? manifest.scenes.find((s) => s.id === under) : null;
blendLightsTo(sceneEntry && sceneEntry.lighting ? sceneEntry.lighting : manifest.lighting);
want();
updateVisibility();
if (onScenes) onScenes({ mode: sceneMode, scene: under, shown: shownScenes, scenes: manifest.scenes || [] });
}
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) && sceneShown(cell.scene) && eye.distanceTo(cell.center) - cell.radius < drawDistance();
}
}
function clear() {
generation++;
requests.abort();
requests = new AbortController();
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();
const mine = generation;
detail = DETAIL[Math.max(0, Math.min(DETAIL.length - 1, level))];
if (!manifest || manifest.url !== url) {
manifest = null;
let loaded = null;
try {
const response = await fetch(url, { credentials: 'same-origin', signal: requests.signal });
if (response.ok) loaded = await response.json();
} catch (error) {
// Aborted by a newer load or clear, or the network failed
}
// Another load or a clear came first: this one's result isn't wanted any more
if (mine !== generation) return false;
if (!loaded) return false;
manifest = loaded;
manifest.url = url;
setGameLights(manifest.lighting);
sceneMap = decodeSceneMap(manifest.sceneMap);
focusScene = null;
byAsset = groupObjects(manifest.objects);
if (manifest.sky >= 0) byAsset.delete(manifest.sky);
}
loadSky();
updateScenes(true);
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);
stepLightBlend(dt);
lastUpdate += dt;
if (lastUpdate < UPDATE_SECONDS) return;
lastUpdate = 0;
updateScenes();
want();
updateVisibility();
},
/**
* Which scenes' objects are drawn: 'all', 'game' (as the game streams them around the focus: the scene under
* it, the ones connected to it and the global scene) or 'manual' (setManualScenes).
*/
setSceneMode(mode) {
sceneMode = mode === 'game' || mode === 'manual' ? mode : 'all';
updateScenes(true);
},
setManualScenes(ids) {
manualScenes = new Set(ids);
if (sceneMode === 'manual') updateScenes(true);
},
/** {mode, scene (under the focus, null without a scene map), shown (Set, null: all), scenes (manifest.scenes)} */
sceneState() {
return { mode: sceneMode, scene: focusScene, shown: shownScenes, scenes: manifest && manifest.scenes ? manifest.scenes : [] };
},
/** The zone's lights as uniforms (updated in place, blends too), for the terrain to be lit like the scenery. */
gameLights() { return gameLights; },
/** 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) };
},
/** Let go of everything loaded and stop what's loading (switching zones); load() starts again. */
clear() {
clear();
manifest = null;
byAsset = new Map();
sceneMap = null;
focusScene = shownScenes = null;
},
dispose() {
clear();
scene.remove(root);
scene.remove(sky);
}
};
}