Files
DarkflameServer/dDashboardServer/static/js/scenery-core.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

633 lines
31 KiB
JavaScript

/**
* The scenery loader's decoding, without three.js so it can be tested with node: the server's converted models
* (NifFile::Encode) and the client's DDS textures (DXT1/3/5 and uncompressed), which the browser decodes itself.
*/
/**
* A model from /api/scenery/:zone/mesh/:asset: {header, meshes: [{...header entry, positions, normals, uvs, colors,
* indices}]} with typed arrays viewing the response (normals int8 x3, colors uint8 RGBA, indices uint16).
*/
export function parseModel(buffer) {
const view = new DataView(buffer);
const length = view.getUint32(0, true);
const header = JSON.parse(new TextDecoder().decode(new Uint8Array(buffer, 4, length)));
const base = 4 + length;
const meshes = header.meshes.map((entry) => {
let offset = base + entry.offset;
const n = entry.vertices;
const mesh = { ...entry };
mesh.positions = new Float32Array(buffer, offset, n * 3);
offset += n * 12;
if (entry.normals) {
mesh.normals = new Int8Array(buffer, offset, n * 3);
offset += (n * 3 + 3) & ~3;
}
if (entry.uv) {
mesh.uvs = new Float32Array(buffer, offset, n * 2);
offset += n * 8;
}
if (entry.uv2) {
mesh.uvs2 = new Float32Array(buffer, offset, n * 2);
offset += n * 8;
}
if (entry.colors) {
mesh.colors = new Uint8Array(buffer, offset, n * 4);
offset += n * 4;
}
mesh.indices = new Uint16Array(buffer, offset, entry.indices);
return mesh;
});
return { header, meshes };
}
/**
* The shader (mapShaders.gameValue) the game draws a mesh of a model with, or null when the manifest doesn't say
* (-1 is fixed function). A multishader model's parts name theirs in their node names (mesh.shaderTag, a mapShaders
* id); a tag the client can't use falls back to the LEGO shader.
*/
export function shaderOf(manifest, asset, mesh) {
if (!manifest || !manifest.shaders) return null;
const shader = manifest.shaders[asset];
if (shader === undefined || shader === null) return null;
if (shader !== manifest.multishader) return shader;
const tagged = mesh && mesh.shaderTag >= 0 && manifest.shaderTags ? manifest.shaderTags[mesh.shaderTag] : undefined;
return tagged !== undefined && tagged >= 3 && tagged <= 108 ? tagged : manifest.defaultShader;
}
// 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,
GLITTER: 512, SPARKLE: 1024 };
/**
* 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 = 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;\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;
#ifdef USE_INSTANCING
glitterAt = instanceMatrix * glitterAt;
glitterNormal = mat3(instanceMatrix) * glitterNormal;
#endif
vGlitterPosition = glitterAt.xyz;
vGlitterNormal = glitterNormal;
` : '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);
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 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,
SUPER_EMISSIVE: 256, GRAYSCALE: 512, SHINY_GLINT: 1024, SPECULAR: 2048, NON_DECAL: 4096, OCEAN_FX: 8192, RIM_LIGHT: 16384,
DIFFUSE_ONLY: 32768, ANIM_ALPHA: 65536, BASIC_EMISSIVE: 131072, NO_FOG: 262144, NOT_DRAWN: 524288, NO_BLEND: 1048576
};
/**
* The conversion format (Scenery.cpp FORMAT_VERSION) these views are written for. Manifest URLs carry it, so a
* browser never draws with a manifest it kept from an older server (they are cached for up to a day).
*/
export const SCENERY_FORMAT = 6;
/** A manifest URL asking for SCENERY_FORMAT's manifest (the server ignores the parameter; browsers cache by it). */
export function manifestUrl(url) {
return url + (url.includes('?') ? '&' : '?') + 'format=' + SCENERY_FORMAT;
}
/**
* Whether a manifest's models are drawn with the game's shaders: it has the zone's lighting and says which technique
* each shader is (format 4 on). Older manifests have neither all shaders' techniques nor the looks the shaders need,
* so they are drawn with the viewer's own lights rather than every shader guessed as LEGO.
*/
export function gameShaded(manifest) {
return !!(manifest && manifest.lighting && (manifest.techniques || manifest.technique));
}
// A technique the manifest doesn't name: the LEGO shader's, as the client falls back to it
const LEGO_TECHNIQUE = { family: 'lego', look: 0, alpha: 'decal', flags: 0 };
const FIXED_TECHNIQUE = { family: 'fixed', look: 0, alpha: 'opacity', flags: 0 };
/**
* The technique (NifFile::TechniqueFor, the manifest's "techniques" by gameValue) a mesh of a model is drawn with:
* {shader, family, look, alpha, flags}. A manifest-wide "technique" (the flairs') is every mesh's; without a shader the
* mesh is fixed function; a shader the table lacks is drawn as LEGO.
*/
export function techniqueOf(manifest, asset, mesh) {
if (manifest && manifest.technique) return { shader: null, ...manifest.technique };
const shader = shaderOf(manifest, asset, mesh);
if (shader === null || shader < 0) return { shader, ...FIXED_TECHNIQUE };
const known = manifest.techniques && manifest.techniques[shader];
return { shader, ...(known || LEGO_TECHNIQUE) };
}
/**
* What a texture's alpha does in the game (the technique's, NifFile::eTextureAlpha): 'opacity' see-through where the
* alpha is; 'decal' the texture is laid over the vertex colors by its alpha (LEGO shaders); 'ignored' it does nothing.
*/
export function textureAlphaMode(manifest, asset, mesh) {
if (!manifest || (!manifest.techniques && !manifest.technique)) {
// A manifest from before the techniques (format 3 and older) names only the shaders whose alpha isn't opacity
const shader = shaderOf(manifest, asset, mesh);
return (shader !== null && manifest && manifest.textureAlpha && manifest.textureAlpha[shader]) || 'opacity';
}
return techniqueOf(manifest, asset, mesh).alpha || 'opacity';
}
/**
* How a mesh is drawn under the game's shaders, when the manifest has the zone's lighting: {family (game-shaders.js),
* lit, texture, vertexColors, material, layers, metal, emissive, textureAlpha, uvAnim, flags, blend, doubleSided,
* hidden} — whether the scene's sun and ambient light it, its texture and vertex colors are used, whether its
* NiMaterialProperty colors are (only fixed function and the "Material" shaders use them), how a two layer shader puts
* its dark texture with the base one ('blended', 'added' or null), whether it is metal ('polished', 'brushed' or null),
* whether it glows (LEGO-Emissive: the vertex alpha is then no opacity), whether its texture moves as the .nif's
* texture transform says, its blending ('nif': as NiAlphaProperty says; 'blend': see-through without depth writes;
* 'test': cut out; 'additive'; 'opaque') and whether the game draws it in the world at all (hidden: post-processing
* and shadow shaders). Null without lighting or techniques in the manifest (older servers, gameShaded), for the
* viewer's own lights.
*/
export function gameLook(manifest, asset, mesh) {
if (!gameShaded(manifest)) return null;
const technique = techniqueOf(manifest, asset, mesh);
const fixedFunction = technique.family === 'fixed';
const bits = technique.look || 0;
const flags = technique.flags || 0;
return {
family: technique.family,
lit: !(bits & SHADER_LOOK.UNLIT),
texture: !(bits & SHADER_LOOK.NO_TEXTURE),
// Fixed function reads them as NiVertexColorProperty says; the shaders always do, unless they have none
vertexColors: !!(mesh.colors && !(bits & SHADER_LOOK.NO_VERTEX_COLORS) && (!fixedFunction || mesh.vertexColors !== 0)),
material: fixedFunction || !!(bits & SHADER_LOOK.MATERIAL_COLOR),
layers: bits & SHADER_LOOK.TWO_LAYERS_BLENDED ? 'blended' : bits & SHADER_LOOK.TWO_LAYERS_ADDED ? 'added' : null,
metal: metalOf(bits),
emissive: !!(bits & SHADER_LOOK.EMISSIVE),
textureAlpha: technique.alpha || 'opacity',
uvAnim: !!(flags & TECHNIQUE.UV_ANIM),
flags,
blend: flags & TECHNIQUE.ADDITIVE ? 'additive' : flags & TECHNIQUE.BLEND ? 'blend' : flags & TECHNIQUE.ALPHA_TEST ? 'test' : flags & TECHNIQUE.NO_BLEND ? 'opaque' : 'nif',
doubleSided: !!(flags & TECHNIQUE.DOUBLE_SIDED),
hidden: !!(flags & TECHNIQUE.NOT_DRAWN)
};
}
/**
* How a mesh drawn with `look` is blended: {transparent, depthWrite, additive, alphaCutoff, doubleSided}. Most of the
* game's files have alpha blending switched on; it only shows where something is see-through (`seeThrough`: the
* material, a vertex or the texture's alpha as opacity). Blended meshes still write depth, as Gamebryo's default does,
* unless the technique turns that off.
*/
export function blendingOf(look, mesh, seeThrough) {
const cutoff = mesh.test >= 0 ? Math.max(mesh.test / 255, 0.01) : 0;
const doubleSided = !!mesh.doubleSided || !!(look && look.doubleSided);
switch (look ? look.blend : 'nif') {
case 'additive': return { transparent: true, depthWrite: false, additive: true, alphaCutoff: 0, doubleSided };
case 'blend': return { transparent: true, depthWrite: false, additive: false, alphaCutoff: cutoff, doubleSided };
case 'test': return { transparent: false, depthWrite: true, additive: false, alphaCutoff: cutoff || 0.5, doubleSided };
case 'opaque': return { transparent: false, depthWrite: true, additive: false, alphaCutoff: 0, doubleSided };
default: return { transparent: !!(mesh.blend && seeThrough), depthWrite: true, additive: false, alphaCutoff: cutoff, doubleSided };
}
}
// A shader's metal from its eShaderLook bits: 'polished', 'brushed' or null
export function metalOf(bits) {
if (!(bits & SHADER_LOOK.REFLECTIVE)) return null;
return bits & SHADER_LOOK.BRUSHED ? 'brushed' : 'polished';
}
/**
* Meshes of a model that look the same (texture, colors, blending, sides, attributes) joined into one, so a model
* made of many pieces (the zones' "glom" files have over a hundred) costs a few draw calls instead of one per piece.
* Order is kept otherwise; indices become 32-bit when a joined mesh passes 65535 vertices.
*/
export function mergeMeshes(meshes) {
const groups = new Map();
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.look, mesh.uvScroll]);
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(mesh);
}
return [...groups.values()].map((list) => {
if (list.length === 1) return list[0];
const first = list[0];
const vertices = list.reduce((sum, m) => sum + m.vertices, 0);
const indexCount = list.reduce((sum, m) => sum + m.indices.length, 0);
const out = { ...first, vertices, positions: new Float32Array(vertices * 3) };
if (first.normals) out.normals = new Int8Array(vertices * 3);
if (first.uvs) out.uvs = new Float32Array(vertices * 2);
if (first.uvs2) out.uvs2 = new Float32Array(vertices * 2);
if (first.colors) out.colors = new Uint8Array(vertices * 4);
out.indices = vertices > 65535 ? new Uint32Array(indexCount) : new Uint16Array(indexCount);
let v = 0, i = 0;
for (const m of list) {
out.positions.set(m.positions, v * 3);
if (out.normals) out.normals.set(m.normals, v * 3);
if (out.uvs) out.uvs.set(m.uvs, v * 2);
if (out.uvs2) out.uvs2.set(m.uvs2, v * 2);
if (out.colors) out.colors.set(m.colors, v * 4);
for (let k = 0; k < m.indices.length; k++) out.indices[i + k] = m.indices[k] + v;
v += m.vertices;
i += m.indices.length;
}
return out;
});
}
// sRGB byte -> linear byte, for vertex colors (three.js takes vertex colors as linear)
const SRGB_TO_LINEAR = new Uint8Array(256);
for (let i = 0; i < 256; i++) {
const c = i / 255;
SRGB_TO_LINEAR[i] = Math.round((c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)) * 255);
}
export function linearColors(srgb) {
const out = new Uint8Array(srgb.length);
for (let i = 0; i < srgb.length; i += 4) {
out[i] = SRGB_TO_LINEAR[srgb[i]];
out[i + 1] = SRGB_TO_LINEAR[srgb[i + 1]];
out[i + 2] = SRGB_TO_LINEAR[srgb[i + 2]];
out[i + 3] = srgb[i + 3];
}
return out;
}
const FOURCC = { 0x31545844: 'DXT1', 0x33545844: 'DXT3', 0x35545844: 'DXT5' };
/**
* A DDS file's header and mipmap levels: {format: 'DXT1'|'DXT3'|'DXT5'|'RGBA', width, height, levels: [{width,
* height, data}]}, starting at the first level no larger than maxSize (so big textures cost less), or null when the
* file isn't a DDS this reads. Uncompressed files (24 or 32 bits with masks) come back as RGBA, one level.
*/
export function parseDds(buffer, maxSize = 4096) {
if (buffer.byteLength < 128) return null;
const view = new DataView(buffer);
if (view.getUint32(0, true) !== 0x20534444) return null; // "DDS "
const height = view.getUint32(12, true), width = view.getUint32(16, true);
const mipCount = Math.max(1, view.getUint32(28, true));
const pfFlags = view.getUint32(80, true);
const fourCC = view.getUint32(84, true);
if (!width || !height || width > 8192 || height > 8192) return null;
let offset = 128;
if (pfFlags & 0x4) {
const format = FOURCC[fourCC];
if (!format) return null;
const blockBytes = format === 'DXT1' ? 8 : 16;
const levels = [];
let w = width, h = height;
for (let i = 0; i < mipCount; i++) {
const size = Math.max(1, (w + 3) >> 2) * Math.max(1, (h + 3) >> 2) * blockBytes;
if (offset + size > buffer.byteLength) break;
levels.push({ width: w, height: h, data: new Uint8Array(buffer, offset, size) });
offset += size;
w = Math.max(1, w >> 1);
h = Math.max(1, h >> 1);
}
let first = 0;
while (first < levels.length - 1 && (levels[first].width > maxSize || levels[first].height > maxSize)) first++;
const chosen = levels.slice(first);
if (!chosen.length) return null;
return { format, width: chosen[0].width, height: chosen[0].height, levels: chosen };
}
if (!(pfFlags & 0x40)) return null;
const bits = view.getUint32(88, true);
if (bits !== 32 && bits !== 24) return null;
const masks = [92, 96, 100, 104].map((at) => view.getUint32(at, true));
const hasAlpha = (pfFlags & 0x1) && masks[3];
const bytes = bits / 8;
if (offset + width * height * bytes > buffer.byteLength) return null;
const shift = (mask) => (mask ? 31 - Math.clz32(mask & -mask) : 0);
const shifts = masks.map(shift);
const rgba = new Uint8Array(width * height * 4);
const src = new Uint8Array(buffer, offset, width * height * bytes);
for (let i = 0, s = 0; i < width * height; i++, s += bytes) {
const pixel = bytes === 4 ? (src[s] | (src[s + 1] << 8) | (src[s + 2] << 16) | (src[s + 3] << 24)) >>> 0 : src[s] | (src[s + 1] << 8) | (src[s + 2] << 16);
rgba[i * 4] = (pixel & masks[0]) >>> shifts[0];
rgba[i * 4 + 1] = (pixel & masks[1]) >>> shifts[1];
rgba[i * 4 + 2] = (pixel & masks[2]) >>> shifts[2];
rgba[i * 4 + 3] = hasAlpha ? (pixel & masks[3]) >>> shifts[3] : 255;
}
const image = downscale({ format: 'RGBA', width, height, levels: [{ width, height, data: rgba }] }, maxSize);
image.alpha = !!hasAlpha;
return image;
}
/**
* A DDS cube map (the client's environment cubes, textures/env and textures/metal) as six RGBA faces no larger than
* maxSize: {faces: [{format: 'RGBA', width, height, data}]} in the file's order (+X, -X, +Y, -Y, +Z, -Z, as WebGL
* takes them). With `plain`, a DDS that isn't a cube comes back as its one face instead; null otherwise.
*/
export function parseDdsCube(buffer, maxSize = 256, plain = false) {
if (buffer.byteLength < 128) return null;
const view = new DataView(buffer);
if (view.getUint32(0, true) !== 0x20534444) return null;
const isCube = (view.getUint32(112, true) & 0x200) !== 0;
if (isCube === plain) return null;
const height = view.getUint32(12, true), width = view.getUint32(16, true);
const mipCount = Math.max(1, view.getUint32(28, true));
const pfFlags = view.getUint32(80, true);
const format = FOURCC[view.getUint32(84, true)];
if (!(pfFlags & 0x4) || !format || !width || !height || width > 4096 || height > 4096) return null;
const blockBytes = format === 'DXT1' ? 8 : 16;
const levelBytes = (w, h) => Math.max(1, (w + 3) >> 2) * Math.max(1, (h + 3) >> 2) * blockBytes;
let faceBytes = 0;
for (let i = 0, w = width, h = height; i < mipCount; i++, w = Math.max(1, w >> 1), h = Math.max(1, h >> 1)) faceBytes += levelBytes(w, h);
const count = isCube ? 6 : 1;
if (128 + faceBytes * count > buffer.byteLength) return null;
const faces = [];
for (let f = 0; f < count; f++) {
const data = new Uint8Array(buffer, 128 + f * faceBytes, levelBytes(width, height));
const rgba = decodeDxt(format, width, height, data);
faces.push(downscale({ format: 'RGBA', width, height, levels: [{ width, height, data: rgba }] }, maxSize).levels[0]);
}
return plain ? { format: 'RGBA', ...faces[0] } : { faces: faces.map((face) => ({ format: 'RGBA', ...face })) };
}
// Halve an RGBA image (box filter) until it fits maxSize
function downscale(image, maxSize) {
let { width, height, data } = image.levels[0];
while (width > maxSize || height > maxSize) {
const w = Math.max(1, width >> 1), h = Math.max(1, height >> 1);
const out = new Uint8Array(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
for (let c = 0; c < 4; c++) {
const at = (dx, dy) => data[((Math.min(y * 2 + dy, height - 1)) * width + Math.min(x * 2 + dx, width - 1)) * 4 + c];
out[(y * w + x) * 4 + c] = (at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1) + 2) >> 2;
}
}
}
width = w; height = h; data = out;
}
return { format: 'RGBA', width, height, levels: [{ width, height, data }] };
}
function color565(value, out, at) {
out[at] = ((value >> 11) & 31) * 255 / 31 | 0;
out[at + 1] = ((value >> 5) & 63) * 255 / 63 | 0;
out[at + 2] = (value & 31) * 255 / 31 | 0;
}
/**
* One DXT level as RGBA bytes (for GPUs without S3TC support, e.g. most phones). DXT1 blocks whose first color is
* not greater than the second have a transparent fourth color.
*/
export function decodeDxt(format, width, height, data) {
const out = new Uint8Array(width * height * 4);
const blockBytes = format === 'DXT1' ? 8 : 16;
const bw = Math.max(1, (width + 3) >> 2), bh = Math.max(1, (height + 3) >> 2);
const palette = new Uint8Array(16);
const alphas = new Uint8Array(16);
for (let by = 0; by < bh; by++) {
for (let bx = 0; bx < bw; bx++) {
const block = (by * bw + bx) * blockBytes;
const colorAt = format === 'DXT1' ? block : block + 8;
const c0 = data[colorAt] | (data[colorAt + 1] << 8), c1 = data[colorAt + 2] | (data[colorAt + 3] << 8);
color565(c0, palette, 0);
color565(c1, palette, 4);
palette[3] = palette[7] = palette[11] = palette[15] = 255;
if (c0 > c1 || format !== 'DXT1') {
for (let c = 0; c < 3; c++) {
palette[8 + c] = (2 * palette[c] + palette[4 + c]) / 3 | 0;
palette[12 + c] = (palette[c] + 2 * palette[4 + c]) / 3 | 0;
}
} else {
for (let c = 0; c < 3; c++) palette[8 + c] = (palette[c] + palette[4 + c]) >> 1;
palette[12] = palette[13] = palette[14] = palette[15] = 0;
}
if (format === 'DXT3') {
for (let i = 0; i < 16; i++) alphas[i] = ((data[block + (i >> 1)] >> ((i & 1) * 4)) & 15) * 17;
} else if (format === 'DXT5') {
const a0 = data[block], a1 = data[block + 1];
const table = [a0, a1];
if (a0 > a1) for (let i = 1; i < 7; i++) table.push(((7 - i) * a0 + i * a1) / 7 | 0);
else { for (let i = 1; i < 5; i++) table.push(((5 - i) * a0 + i * a1) / 5 | 0); table.push(0, 255); }
// 48 bits of 3-bit indices
let bits = 0, count = 0, byte = block + 2;
for (let i = 0; i < 16; i++) {
if (count < 3) { bits |= data[byte++] << count; count += 8; }
alphas[i] = table[bits & 7];
bits >>= 3; count -= 3;
}
}
const indices = data[colorAt + 4] | (data[colorAt + 5] << 8) | (data[colorAt + 6] << 16) | (data[colorAt + 7] << 24);
for (let i = 0; i < 16; i++) {
const x = bx * 4 + (i & 3), y = by * 4 + (i >> 2);
if (x >= width || y >= height) continue;
const p = ((indices >>> (i * 2)) & 3) * 4;
const at = (y * width + x) * 4;
out[at] = palette[p];
out[at + 1] = palette[p + 1];
out[at + 2] = palette[p + 2];
out[at + 3] = format === 'DXT1' ? palette[p + 3] : alphas[i];
}
}
}
return out;
}
/** A level chain is complete (usable for mipmapping) when it runs down to 1x1. */
export function completeChain(levels) {
const last = levels[levels.length - 1];
return levels.length > 0 && last.width === 1 && last.height === 1;
}
/**
* Objects of a manifest grouped by model: Map asset -> [{index, x, y, z, qx, qy, qz, qw, scale}].
*/
export function groupObjects(objects) {
const byAsset = new Map();
for (let i = 0; i < objects.asset.length; i++) {
const asset = objects.asset[i];
if (!byAsset.has(asset)) byAsset.set(asset, []);
byAsset.get(asset).push({
index: i, x: objects.pos[i * 3], y: objects.pos[i * 3 + 1], z: objects.pos[i * 3 + 2],
qx: objects.rot[i * 4], qy: objects.rot[i * 4 + 1], qz: objects.rot[i * 4 + 2], qw: objects.rot[i * 4 + 3], scale: objects.scale[i],
// A tint per object ([r, g, b, ...]), for the flairs
color: objects.color ? [objects.color[i * 3], objects.color[i * 3 + 1], objects.color[i * 3 + 2]] : null,
// The game doesn't draw it (a trigger or blocking volume): only shown on request
hidden: !!(objects.hidden && objects.hidden[i]),
// The zone scene it was placed in (null: the manifest has none), for scenes like the game
scene: objects.scene ? objects.scene[i] : null
});
}
return byAsset;
}
/** Split instances into square cells `size` wide (by x and z): Map "cx,cz" -> instances. */
export function cellsOf(instances, size) {
const cells = new Map();
for (const instance of instances) {
const key = Math.floor(instance.x / size) + ',' + Math.floor(instance.z / size);
if (!cells.has(key)) cells.set(key, []);
cells.get(key).push(instance);
}
return cells;
}
// ---- Scenes, as the game client streams them (ZoneScenes on the server) ----
export const GLOBAL_SCENE = 0;
const NO_SCENE = 255;
/**
* The manifest's terrain scene map (sceneMap: {chunks: [{x, z, maxX, maxZ, size, runs (base64)}]}) ready for
* sceneAt, or null without one.
*/
export function decodeSceneMap(json) {
if (!json || !json.chunks || !json.chunks.length) return null;
const chunks = json.chunks.map((c) => {
// Runs of [length, scene] (ZoneScenes::RunLengths)
const bytes = typeof atob === 'function' ? atob(c.runs) : Buffer.from(c.runs, 'base64').toString('binary');
const cells = new Uint8Array(c.size * c.size).fill(NO_SCENE);
for (let i = 0, at = 0; i + 1 < bytes.length && at < cells.length; i += 2) {
const length = bytes.charCodeAt(i);
cells.fill(bytes.charCodeAt(i + 1), at, Math.min(at + length, cells.length));
at += length;
}
// Cells per unit as the client works it out, in 32-bit floats
const perX = Math.fround(c.size / Math.fround(c.maxX - c.x)), perZ = Math.fround(c.size / Math.fround(c.maxZ - c.z));
return { ...c, cells, perX, perZ };
});
return {
chunks,
minX: Math.min(...chunks.map((c) => c.x)), minZ: Math.min(...chunks.map((c) => c.z)),
maxX: Math.max(...chunks.map((c) => c.maxX)), maxZ: Math.max(...chunks.map((c) => c.maxZ))
};
}
/**
* The scene at (x, z) as the client's TerrainManager::GetSceneAtPos finds it (ZoneScenes::SceneMap::SceneAt): the
* nearest cell of the chunk under the point, the point clamped to the terrain; the global scene where there's none.
*/
export function sceneAt(map, x, z) {
if (!map || !Number.isFinite(x) || !Number.isFinite(z)) return GLOBAL_SCENE;
const EDGE = 0.001;
x = Math.min(Math.max(x, map.minX), map.maxX);
z = Math.min(Math.max(z, map.minZ), map.maxZ);
for (const c of map.chunks) {
const px = Math.min(x, map.maxX - 1 / c.perX), pz = Math.min(z, map.maxZ - 1 / c.perZ);
if (px < c.x - EDGE || px >= c.maxX - EDGE || pz < c.z - EDGE || pz >= c.maxZ - EDGE) continue;
const cx = Math.min(Math.max(Math.floor(c.perX * (px - c.x) + 0.5), 0), c.size - 1);
const cz = Math.min(Math.max(Math.floor(c.perZ * (pz - c.z) + 0.5), 0), c.size - 1);
const scene = c.cells[cx * c.size + cz];
return scene === NO_SCENE ? GLOBAL_SCENE : scene;
}
return GLOBAL_SCENE;
}
/** The scenes the client keeps loaded with the player in `scene` (manifest.scenes): the global scene, it and its neighbours. */
export function loadedScenes(scenes, scene) {
const loaded = new Set([GLOBAL_SCENE]);
if (scene === GLOBAL_SCENE) return loaded;
loaded.add(scene);
const entry = (scenes || []).find((s) => s.id === scene);
if (entry) for (const n of entry.neighbours) loaded.add(n);
return loaded;
}
/**
* The camera's near plane for a view `distance` from what it looks at. A depth buffer's precision goes with
* near / distance², so a fixed small near plane (0.5) makes coplanar pieces fight (ground overlays, floor rings, road
* pieces) once the camera is far out; the game's own camera stays close to the player. A four hundredth
* of the distance keeps close-ups working and far views steady.
*/
export function nearPlaneFor(distance) {
return Math.min(20, Math.max(0.5, distance / 400));
}