import * as THREE from 'three'; import { OrbitControls } from '/vendor/OrbitControls.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 = []; 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 = []; } 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; } buildMaterial(material, txd, report) { const params = { color: new THREE.Color(material.color[0], material.color[1], material.color[2]), side: THREE.DoubleSide, shininess: 8, }; if (material.texture && material.texture.name) { const key = material.texture.name.toLowerCase(); const source = txd && txd.textures.get(key); if (source) { params.map = this.makeTexture(source); 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); params.color = new THREE.Color(MISSING_COLOR); } } if (material.color[3] < 1) { params.transparent = true; params.opacity = material.color[3]; } return new THREE.MeshPhongMaterial(params); } 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; } // 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; } load(dff, txd) { this.clear(); const report = { missingTextures: new Set(), meshes: 0, triangles: 0, vertices: 0 }; 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 })); for (const atomic of atomics) { const geo = dff.geometries[atomic.geometry]; if (!geo) continue; const mesh = new THREE.Mesh( this.buildGeometry(geo), geo.materials.length ? geo.materials.map((m) => this.buildMaterial(m, txd, report)) : new THREE.MeshPhongMaterial({ color: MISSING_COLOR, side: THREE.DoubleSide }) ); mesh.applyMatrix4(this.worldMatrix(dff.frames, atomic.frame, geo.skinned)); mesh.name = (dff.frames[atomic.frame] && dff.frames[atomic.frame].name) || 'atomic' + report.meshes; root.add(mesh); report.meshes++; report.vertices += geo.numVertices; report.triangles += geo.indices.length / 3; } this.scene.add(root); this.model = root; this.frame(); return report; } 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(); } }