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(); } }