365 lines
13 KiB
JavaScript
365 lines
13 KiB
JavaScript
import * as THREE from 'three';
|
|
import { OrbitControls } from '/vendor/OrbitControls.js';
|
|
import { planVehicle, upgradeAtomics, paintSlot, DEFAULT_PAINT } from './vehicle.js';
|
|
|
|
const MISSING_COLOR = 0xb0b0b0;
|
|
|
|
export class Viewer {
|
|
constructor(canvas) {
|
|
this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
|
|
this.scene = new THREE.Scene();
|
|
this.scene.background = new THREE.Color(0x171a1f);
|
|
|
|
this.camera = new THREE.PerspectiveCamera(45, 1, 0.01, 2000);
|
|
this.camera.position.set(1.5, 1.2, 2.5);
|
|
|
|
this.controls = new OrbitControls(this.camera, canvas);
|
|
this.controls.enableDamping = true;
|
|
|
|
this.scene.add(new THREE.AmbientLight(0xffffff, 1.1));
|
|
const key = new THREE.DirectionalLight(0xffffff, 1.6);
|
|
key.position.set(3, 6, 4);
|
|
this.scene.add(key);
|
|
const fill = new THREE.DirectionalLight(0xffffff, 0.7);
|
|
fill.position.set(-4, 2, -3);
|
|
this.scene.add(fill);
|
|
|
|
this.model = null;
|
|
this.textures = [];
|
|
this.txds = [];
|
|
|
|
window.addEventListener('resize', () => this.resize());
|
|
this.resize();
|
|
this.renderer.setAnimationLoop(() => {
|
|
this.controls.update();
|
|
this.renderer.render(this.scene, this.camera);
|
|
});
|
|
}
|
|
|
|
resize() {
|
|
const canvas = this.renderer.domElement;
|
|
const width = canvas.clientWidth || 1;
|
|
const height = canvas.clientHeight || 1;
|
|
this.renderer.setSize(width, height, false);
|
|
this.camera.aspect = width / height;
|
|
this.camera.updateProjectionMatrix();
|
|
}
|
|
|
|
clear() {
|
|
if (this.model) {
|
|
this.model.traverse((obj) => {
|
|
if (obj.geometry) obj.geometry.dispose();
|
|
if (obj.material) [].concat(obj.material).forEach((m) => m.dispose());
|
|
});
|
|
this.scene.remove(this.model);
|
|
this.model = null;
|
|
}
|
|
this.textures.forEach((t) => t.dispose());
|
|
this.textures = [];
|
|
this.txds = [];
|
|
this.paintjob = null;
|
|
this.paintColors = null;
|
|
}
|
|
|
|
makeTexture(source) {
|
|
const texture = new THREE.DataTexture(source.rgba, source.width, source.height, THREE.RGBAFormat);
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
texture.generateMipmaps = true;
|
|
texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
|
|
// RenderWare UVs put (0,0) at the top left, which matches the raw row order.
|
|
texture.flipY = false;
|
|
texture.needsUpdate = true;
|
|
this.textures.push(texture);
|
|
return texture;
|
|
}
|
|
|
|
// Vehicles reference the shared generic/vehicle.txd as well as their own, so
|
|
// lookups walk the dictionaries in the order they were given.
|
|
findTexture(name) {
|
|
const key = name.toLowerCase();
|
|
for (const txd of this.txds) {
|
|
const source = txd.textures.get(key);
|
|
if (source) return source;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
buildMaterial(material, report, paint) {
|
|
// A paint material keeps its colour when textured rather than being reset
|
|
// to white: the game multiplies the grunge map over the body colour, which
|
|
// is what map * color does here.
|
|
const slot = paint ? paintSlot(material.color) : -1;
|
|
|
|
const params = {
|
|
color: new THREE.Color(material.color[0], material.color[1], material.color[2]),
|
|
side: THREE.DoubleSide,
|
|
shininess: 8,
|
|
};
|
|
if (slot >= 0) params.color = paint[slot] || paint.fallback;
|
|
|
|
if (material.texture && material.texture.name) {
|
|
const source = this.findTexture(material.texture.name);
|
|
if (source) {
|
|
params.map = this.makeTexture(source);
|
|
if (slot < 0) params.color = new THREE.Color(0xffffff);
|
|
if (source.hasAlpha) {
|
|
params.transparent = true;
|
|
params.alphaTest = 0.35;
|
|
params.depthWrite = true;
|
|
}
|
|
} else {
|
|
report.missingTextures.add(material.texture.name);
|
|
if (slot < 0) params.color = new THREE.Color(MISSING_COLOR);
|
|
}
|
|
}
|
|
|
|
if (material.color[3] < 1) {
|
|
params.transparent = true;
|
|
params.opacity = material.color[3];
|
|
}
|
|
const built = new THREE.MeshPhongMaterial(params);
|
|
if (slot >= 0) built.userData.paintSlot = slot;
|
|
// A vehicle that takes paintjobs marks the panels one covers by giving them
|
|
// a remap* texture. Fitting a paintjob swaps that texture out.
|
|
if (material.texture && /^remap/i.test(material.texture.name)) built.userData.remap = true;
|
|
return built;
|
|
}
|
|
|
|
buildGeometry(geo) {
|
|
const geometry = new THREE.BufferGeometry();
|
|
geometry.setAttribute('position', new THREE.BufferAttribute(geo.positions, 3));
|
|
if (geo.uvs) geometry.setAttribute('uv', new THREE.BufferAttribute(geo.uvs, 2));
|
|
if (geo.colors) geometry.setAttribute('color', new THREE.BufferAttribute(geo.colors, 3));
|
|
geometry.setIndex(new THREE.BufferAttribute(geo.indices, 1));
|
|
geo.groups.forEach((g) => geometry.addGroup(g.start, g.count, g.material));
|
|
if (geo.normals) geometry.setAttribute('normal', new THREE.BufferAttribute(geo.normals, 3));
|
|
else geometry.computeVertexNormals();
|
|
return geometry;
|
|
}
|
|
|
|
buildMesh(geo, report, paint) {
|
|
return new THREE.Mesh(
|
|
this.buildGeometry(geo),
|
|
geo.materials.length
|
|
? geo.materials.map((m) => this.buildMaterial(m, report, paint))
|
|
: new THREE.MeshPhongMaterial({ color: MISSING_COLOR, side: THREE.DoubleSide })
|
|
);
|
|
}
|
|
|
|
// Counted per instance rather than per build, because the wheel is built once
|
|
// and drawn four times.
|
|
countMesh(report, geo) {
|
|
report.meshes++;
|
|
report.vertices += geo.numVertices;
|
|
report.triangles += geo.indices.length / 3;
|
|
}
|
|
|
|
// Frames form a tree; an atomic is placed by its frame's world transform.
|
|
// Skinned geometry is the exception: its vertices are stored in the space of
|
|
// the atomic frame's *parent*, so that frame's own transform is skipped.
|
|
worldMatrix(frames, index, skipSelf) {
|
|
const matrix = new THREE.Matrix4();
|
|
const chain = [];
|
|
let i = index;
|
|
let guard = 0;
|
|
while (i >= 0 && i < frames.length && guard++ < frames.length) {
|
|
chain.unshift(frames[i]);
|
|
i = frames[i].parent;
|
|
}
|
|
if (skipSelf) chain.pop();
|
|
for (const frame of chain) {
|
|
matrix.multiply(new THREE.Matrix4().fromArray(frame.matrix));
|
|
}
|
|
return matrix;
|
|
}
|
|
|
|
// colors is a list of [r, g, b] bytes, one per paint slot. They come from
|
|
// carcols, so they are sRGB rather than working space values.
|
|
makePaint(colors) {
|
|
// A slot with no colour is left undefined so it falls back below, which is
|
|
// what a vehicle that only paints two of its four slots wants.
|
|
const paint = (colors || []).map((c) =>
|
|
Array.isArray(c)
|
|
? new THREE.Color().setRGB(c[0] / 255, c[1] / 255, c[2] / 255, THREE.SRGBColorSpace)
|
|
: undefined
|
|
);
|
|
paint.fallback = new THREE.Color().setRGB(
|
|
DEFAULT_PAINT[0] / 255,
|
|
DEFAULT_PAINT[1] / 255,
|
|
DEFAULT_PAINT[2] / 255,
|
|
THREE.SRGBColorSpace
|
|
);
|
|
return paint;
|
|
}
|
|
|
|
// txd is one dictionary or a list of them, searched in order.
|
|
// options: { vehicle: true, paint: [[r, g, b], ...] }
|
|
load(dff, txd, options = {}) {
|
|
this.clear();
|
|
this.txds = [].concat(txd || []).filter(Boolean);
|
|
const report = { missingTextures: new Set(), meshes: 0, triangles: 0, vertices: 0 };
|
|
this.paintColors = options.paint || null;
|
|
const paint = options.vehicle ? this.makePaint(options.paint) : null;
|
|
|
|
const root = new THREE.Object3D();
|
|
// RenderWare is Z up; three.js is Y up.
|
|
root.rotation.x = -Math.PI / 2;
|
|
|
|
const atomics = dff.atomics.length
|
|
? dff.atomics
|
|
: dff.geometries.map((_, i) => ({ geometry: i, frame: -1 }));
|
|
|
|
const plan = options.vehicle ? planVehicle(dff, options.upgrades) : null;
|
|
|
|
atomics.forEach((atomic, index) => {
|
|
if (plan && plan.hide.has(index)) return;
|
|
const geo = dff.geometries[atomic.geometry];
|
|
if (!geo) return;
|
|
const mesh = this.buildMesh(geo, report, paint);
|
|
mesh.applyMatrix4(this.worldMatrix(dff.frames, atomic.frame, geo.skinned));
|
|
mesh.name = (dff.frames[atomic.frame] && dff.frames[atomic.frame].name) || 'atomic' + index;
|
|
root.add(mesh);
|
|
this.countMesh(report, geo);
|
|
});
|
|
|
|
if (plan) {
|
|
this.addWheels(root, dff, plan, report, paint, options);
|
|
this.addParts(root, dff, plan, report, paint);
|
|
}
|
|
|
|
this.scene.add(root);
|
|
this.model = root;
|
|
if (options.paintjob) this.applyPaintjob(options.paintjob);
|
|
this.frame();
|
|
return report;
|
|
}
|
|
|
|
// A paintjob is a one texture dictionary that stands in for the remap texture
|
|
// on the body panels. The artwork carries the colour, so those panels stop
|
|
// taking the primary paint while one is fitted. Pass null to strip it.
|
|
applyPaintjob(txd) {
|
|
if (!this.model) return;
|
|
this.paintjob = txd || null;
|
|
const source = txd ? [...txd.textures.values()][0] : null;
|
|
const map = source ? this.makeTexture(source) : null;
|
|
const paint = this.makePaint(this.paintColors);
|
|
|
|
this.model.traverse((obj) => {
|
|
[].concat(obj.material || []).forEach((material) => {
|
|
if (!material.userData.remap) return;
|
|
if (map) {
|
|
if (material.userData.baseMap === undefined) material.userData.baseMap = material.map;
|
|
material.map = map;
|
|
material.color.set(0xffffff);
|
|
} else {
|
|
if (material.userData.baseMap !== undefined) material.map = material.userData.baseMap;
|
|
const slot = material.userData.paintSlot;
|
|
if (slot !== undefined) material.color.copy(paint[slot] || paint.fallback);
|
|
}
|
|
material.needsUpdate = true;
|
|
});
|
|
});
|
|
}
|
|
|
|
// The wheel is built once and cloned per corner, so all four instances share
|
|
// one geometry and one material set.
|
|
addWheels(root, dff, plan, report, paint, options) {
|
|
if (!plan.wheels.length) return;
|
|
|
|
// A stock wheel already comes at the vehicle's own size; an upgrade wheel is
|
|
// modelled at unit diameter and the ide's wheel scale is what sizes it.
|
|
// Measured: infernus stock 0.70 with scale 0.70, linerun 1.10 with 1.10,
|
|
// every wheel_* upgrade 0.99.
|
|
let source = dff;
|
|
let atomic = dff.atomics[plan.wheels[0].atomic];
|
|
let scale = 1;
|
|
// A fitted wheel is re-framed onto the dummy like any other part, so its
|
|
// own root transform goes; the stock one is part of the vehicle's own
|
|
// hierarchy and keeps it.
|
|
let local = new THREE.Matrix4();
|
|
if (plan.wheelModel) {
|
|
source = plan.wheelModel.dff;
|
|
atomic = upgradeAtomics(source)[0];
|
|
scale = options.wheelScale || 1;
|
|
} else if (atomic) {
|
|
local = new THREE.Matrix4().fromArray(source.frames[atomic.frame].matrix);
|
|
}
|
|
if (!atomic) return;
|
|
const geo = source.geometries[atomic.geometry];
|
|
if (!geo) return;
|
|
|
|
// The base is never added to the scene and never transformed: every corner
|
|
// clones it, so no corner inherits another's placement.
|
|
const base = this.buildMesh(geo, report, paint);
|
|
|
|
for (const wheel of plan.wheels) {
|
|
const mesh = base.clone();
|
|
const matrix = this.worldMatrix(dff.frames, wheel.frame, false);
|
|
if (wheel.mirror) matrix.multiply(new THREE.Matrix4().makeScale(-1, 1, 1));
|
|
if (scale !== 1) matrix.multiply(new THREE.Matrix4().makeScale(scale, scale, scale));
|
|
matrix.multiply(local);
|
|
mesh.applyMatrix4(matrix);
|
|
mesh.name = dff.frames[wheel.frame].name;
|
|
root.add(mesh);
|
|
this.countMesh(report, geo);
|
|
}
|
|
}
|
|
|
|
// Upgrade parts are separate clumps hung off a dummy frame in the vehicle.
|
|
// Their textures all come from the shared vehicle.txd, which is already in
|
|
// this.txds, and their paint masks go through buildMaterial like any other.
|
|
addParts(root, dff, plan, report, paint) {
|
|
for (const part of plan.parts) {
|
|
const upgrade = part.upgrade.dff;
|
|
const attach = this.worldMatrix(dff.frames, part.frame, false);
|
|
for (const atomic of upgradeAtomics(upgrade)) {
|
|
const geo = upgrade.geometries[atomic.geometry];
|
|
if (!geo) continue;
|
|
const mesh = this.buildMesh(geo, report, paint);
|
|
mesh.applyMatrix4(attach);
|
|
mesh.name = part.upgrade.name;
|
|
root.add(mesh);
|
|
this.countMesh(report, geo);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Repaint in place - no reparse, no rebuild.
|
|
setPaint(colors) {
|
|
if (!this.model) return;
|
|
this.paintColors = colors;
|
|
const paint = this.makePaint(colors);
|
|
this.model.traverse((obj) => {
|
|
[].concat(obj.material || []).forEach((material) => {
|
|
const slot = material.userData && material.userData.paintSlot;
|
|
if (slot === undefined) return;
|
|
// A panel under a paintjob shows the artwork, not the body colour.
|
|
if (this.paintjob && material.userData.remap) return;
|
|
material.color.copy(paint[slot] || paint.fallback);
|
|
});
|
|
});
|
|
}
|
|
|
|
frame() {
|
|
if (!this.model) return;
|
|
const box = new THREE.Box3().setFromObject(this.model);
|
|
if (box.isEmpty()) return;
|
|
const size = box.getSize(new THREE.Vector3());
|
|
const center = box.getCenter(new THREE.Vector3());
|
|
const radius = Math.max(size.x, size.y, size.z) * 0.5 || 1;
|
|
|
|
this.controls.target.copy(center);
|
|
this.camera.position.copy(center).add(new THREE.Vector3(radius * 1.4, radius * 0.8, radius * 2.2));
|
|
this.camera.near = radius / 100;
|
|
this.camera.far = radius * 100;
|
|
this.camera.updateProjectionMatrix();
|
|
this.controls.update();
|
|
}
|
|
}
|