Initial commit

master
eater 4 years ago
commit cdb6c25aae
Signed by: eater
GPG Key ID: AD2560A0F84F0759

5
.gitignore vendored

@ -0,0 +1,5 @@
dist
node_modules
public/blob/textures/*
!public/blob/textures/.gitkeep
public/blob/meshes.json

@ -0,0 +1,30 @@
# Morrowind Three.JS
A project that takes morrowind's player heads and shows it on a webpage
![](examples/Z9x9jF6.png)
![](examples/ZmJe6PQ.png)
## Requirements
- pyFFI
- yarn / npm
# Setup
```bash
cd [project]
# Copy textures
cp [morrowind]/Data\ Files/Textures/TX_B_N_* ./public/blob/textures
# Generate JSON meshes
./script/export_nif.py [morrowind]/Data\ Files/Meshes/b > ./public/blob/meshes.json
# Pack JS
yarn
yarn webpack
# Run dev http server via PHP e.g.
cd public;
php -S 0:8888;
```
meshes.json is not included because it's easily around 15MB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

@ -0,0 +1,17 @@
{
"name": "morrowinds-threejs",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"three": "^0.113.2",
"webpack": "^4.41.6"
},
"devDependencies": {
"css-loader": "^3.4.2",
"file-loader": "^5.1.0",
"source-map-loader": "^0.2.4",
"style-loader": "^1.1.3",
"webpack-cli": "^3.3.11"
}
}

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<style>
body {
background-color: #ffffff;
margin: 0;
overflow: hidden;
box-sizing: border-box;
}
.overlay {
position: absolute;
margin: 25px;
}
.wizard {
position: absolute;
padding: 25px;
width: 100%;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<div class="overlay">
<div class="part-1">
<label for="part_1">Hair:</label>
<select id="part_1">
<option>Loading...</option>
</select>
</div>
<div class="part-2">
<label for="part_2">Head:</label>
<select id="part_2">
<option>Loading...</option>
</select>
</div>
</div>
<div class="wizard">
<div class="race">
<label for="race">Race: </label>
<select name="race" id="race">
<option>Loading...</option>
</select>
</div>
<div class="gender">
<label for="gender">Gender: </label>
<select name="gender" id="gender">
<option>Loading...</option>
</select>
</div>
<div class="hair">
<button id="prev_hair"><</button>
Hair
<button id="next_hair">></button>
</div>
<div class="head">
<button id="prev_head"><</button>
Head
<button id="next_head">></button>
</div>
</div>
<script src="js/dist/bundle.js"></script>
</body>
</html>

@ -0,0 +1,93 @@
#!/usr/bin/env python3
import os
import sys
from pyffi.formats.nif import NifFormat
import json
def export(path):
data = NifFormat.Data()
with open(path, 'rb') as f:
data.inspect(f)
data.read(f)
objects = []
for root in data.roots:
name = root.name.decode('utf-8').lower()
# For this project, only hair and head matters
if 'hair' in name or 'head' in name:
sys.stderr.write("> " + name + "\n")
else:
sys.stderr.write(" " + name + "\n")
continue
if isinstance(root, NifFormat.NiNode):
objects.append(export_object(root))
# Some NiTriShape's are a root object
if isinstance(root, NifFormat.NiTriShape):
objects.append({'name': root.name.decode('utf-8'), 'shapes': [export_shape(root)]})
return objects
def export_object(root: NifFormat.NiNode):
obj = {'name': root.name.decode("utf-8"), 'shapes': []}
for shape in root.get_children():
if not isinstance(shape, NifFormat.NiTriShape):
continue
obj['shapes'].append(export_shape(shape))
return obj
def export_shape(shape: NifFormat.NiTriShape):
vertices = [(vertice.x, vertice.y, vertice.z) for vertice in list(shape.data.vertices)]
faces = shape.data.get_triangles()
uv_sets = [[[coord.u, coord.v] for coord in uv_set] for uv_set in shape.data.uv_sets]
normals = [(vertice.x, vertice.y, vertice.z) for vertice in list(shape.data.normals)]
texture = None
for property in shape.get_properties():
if isinstance(property, NifFormat.NiTexturingProperty):
texture = export_texture(property.base_texture)
return {
'name': shape.name.decode("utf-8"),
'vertices': vertices,
'faces': faces,
'uvSets': uv_sets,
'normals': normals,
'texture': texture,
'translation': (shape.translation.x, shape.translation.y, shape.translation.z),
'rotation': shape.rotation.as_list(),
}
def export_texture(texture):
wrap_s = texture.clamp_mode in [2, 3]
wrap_t = texture.clamp_mode in [1, 3]
source = texture.source
return {
'wrapS': wrap_s,
'wrapT': wrap_t,
'uvSet': texture.uv_set,
'file': source.file_name.decode("utf-8")
}
if __name__ == '__main__':
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <dir>")
exit(1)
objects = []
for item in os.scandir(sys.argv[1]):
if item.is_file():
objects.extend(export(item.path))
print(json.dumps(objects))

@ -0,0 +1,66 @@
import * as THREE from 'three'
import {Color, Group} from 'three'
import {OrbitControls} from "three/examples/jsm/controls/OrbitControls";
export class HeadRenderer {
constructor() {
this.shapes = {};
this.scene = null;
this.renderer = null;
this.camera = null;
}
init() {
const W = window.innerWidth, H = window.innerHeight;
this.renderer = new THREE.WebGLRenderer();
this.renderer.setSize(W, H);
document.body.appendChild(this.renderer.domElement);
this.camera = new THREE.PerspectiveCamera(70, W / H, 1, 2000);
this.camera.position.z = 600;
this.scene = new THREE.Scene();
this.scene.background = new Color(0xffffff);
let light = new THREE.PointLight(0xffffff, 1);
light.position.set(1, 1, 2);
// Make sure light follow the camera
this.camera.add(light);
// Add camera to scene so light gets rendered
this.scene.add(this.camera);
let ambientLight = new THREE.AmbientLight(0x404040, 1);
this.scene.add(ambientLight);
new OrbitControls(this.camera, this.renderer.domElement);
if (typeof __THREE_DEVTOOLS__ !== 'undefined') {
__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent('observe', {detail: this.scene}));
__THREE_DEVTOOLS__.dispatchEvent(new CustomEvent('observe', {detail: this.renderer}));
}
}
draw() {
window.requestAnimationFrame(() => this.draw());
this.renderer.render(this.scene, this.camera);
}
add(shapeCollection) {
this.shapes[shapeCollection.id] = shapeCollection;
this.scene.add(shapeCollection.getGroup());
}
remove(shapeCollection) {
delete this.shapes[shapeCollection.id];
this.scene.remove(shapeCollection.getGroup());
}
expose() {
window.scene = this.scene;
window.camera = this.camera;
window.renderer = this.renderer;
}
}

@ -0,0 +1,151 @@
import {
Face3,
Geometry, Matrix4,
Mesh, MeshPhongMaterial,
RepeatWrapping,
TextureLoader,
Vector2,
Vector3,
} from 'three'
import {uuidv4} from "./util";
import {TGALoader} from "three/examples/jsm/loaders/TGALoader";
import {DDSLoader} from "three/examples/jsm/loaders/DDSLoader";
export class Shape {
constructor({
vertices = [],
faces = [],
texture = null,
uvSets = [],
normals = [],
translation = [0, 0, 0],
rotation
} = {}, filePrefix = "") {
this.id = uuidv4();
this.vertices = vertices;
this.translation = translation;
this.rotation = rotation;
this.faces = faces;
this.textureInfo = texture;
this.normals = normals;
this.uvSets = uvSets;
this.filePrefix = filePrefix;
this.geometry = null;
this.mesh = null;
this.texture = null;
this.material = null;
}
getMaterial() {
if (this.material) {
return this.material;
}
const params = {
color: 0xffffff,
transparent: true,
alphaTest: 0.5,
};
if (this.textureInfo) {
let Loader;
if (this.textureInfo.file.toLowerCase().endsWith('.tga')) {
Loader = TGALoader
} else if (this.textureInfo.file.toLowerCase().endsWith('.dds')) {
Loader = DDSLoader
} else {
Loader = TextureLoader
}
this.texture = new Loader().load(this.filePrefix + this.textureInfo.file, () => {
}, null, (err) => {
console.log(err)
});
if (this.textureInfo.wrapS) {
this.texture.wrapS = RepeatWrapping;
}
if (this.textureInfo.wrapT) {
this.texture.wrapT = RepeatWrapping;
}
this.texture.flipY = false;
params.map = this.texture;
}
return this.material = new MeshPhongMaterial(params);
}
getGeometry() {
if (this.geometry) {
return this.geometry;
}
const geometry = this.geometry = new Geometry();
for (let [x, y, z] of this.vertices) {
geometry.vertices.push(new Vector3(x, y, z))
}
const uvSets = [];
for (let i = 0; i < this.uvSets.length; i++) {
uvSets.push([]);
}
for (let i = 0; i < this.faces.length; i++) {
let [a, b, c] = this.faces[i];
// Add faces with normals
// Normals are stored per vector
geometry.faces.push(new Face3(a, b, c, [
new Vector3(this.normals[a][0], this.normals[a][1], this.normals[a][2]),
new Vector3(this.normals[b][0], this.normals[b][1], this.normals[b][2]),
new Vector3(this.normals[c][0], this.normals[c][1], this.normals[c][2])
]));
for (let j = 0; j < this.uvSets.length; j++) {
uvSets[j].push([
new Vector2(this.uvSets[j][a][0], this.uvSets[j][a][1]),
new Vector2(this.uvSets[j][b][0], this.uvSets[j][b][1]),
new Vector2(this.uvSets[j][c][0], this.uvSets[j][c][1]),
])
}
}
geometry.faceVertexUvs = uvSets;
// First rotate
let m = new Matrix4();
m.makeBasis(new Vector3(...this.rotation[0]), new Vector3(...this.rotation[1]), new Vector3(...this.rotation[2]));
geometry.applyMatrix4(m);
// Then translate
geometry.translate(this.translation[0], this.translation[1], this.translation[2]);
// Scale up a bit
geometry.scale(20, 20, 20);
// Rotate the face so it faces us \o/
geometry.rotateX(290 * (Math.PI / 180));
geometry.computeFaceNormals();
geometry.computeVertexNormals();
return this.geometry;
}
getMesh() {
if (this.mesh) {
return this.mesh;
}
this.mesh = new Mesh(this.getGeometry(), this.getMaterial());
return this.mesh;
}
}

@ -0,0 +1,26 @@
import {Shape} from "./Shape";
import {Group} from "three";
import {uuidv4} from "./util";
export class ShapeCollection {
constructor({shapes = [], name = ""}, filePrefix = "") {
this.id = uuidv4();
this.shapes = shapes.map((s) => new Shape(s, filePrefix));
this.group = null;
}
getGroup() {
if (this.group) {
return this.group;
}
// Create group for all shapes
this.group = new Group();
for (let shape of this.shapes) {
this.group.add(shape.getMesh())
}
return this.group
}
}

@ -0,0 +1,170 @@
import {HeadRenderer} from "./HeadRenderer";
import * as THREE from "three";
import {ShapeCollection} from "./ShapeCollection";
import {uuidv4} from "./util";
const headRenderer = window.headRenderer = new HeadRenderer();
const part1 = document.querySelector("#part_1");
let shape1 = null;
const part2 = document.querySelector("#part_2");
let shape2 = null;
const race = document.querySelector("#race");
const gender = document.querySelector("#gender");
const prevHair = document.querySelector('#prev_hair');
const nextHair = document.querySelector('#next_hair');
const prevHead = document.querySelector('#prev_head');
const nextHead = document.querySelector('#next_head');
window.addEventListener("load", async () => {
const allMeshes = await (await fetch("blob/meshes.json")).json();
part1.innerHTML = "";
part2.innerHTML = "";
const indexed = {};
const tree = {};
const shapeCache = {};
headRenderer.init();
headRenderer.expose();
for (let mesh of allMeshes) {
let [_, x, race, gender, part, nr = undefined] = mesh.name.split('_');
race = race.replace(new RegExp("\\s+"), ' ');
if (race.toLowerCase() === "khajiit") {
console.log(part, mesh.name, nr);
}
if (x !== 'N') {
continue;
}
if (nr === undefined) {
[_, part, nr] = part.match(/(head|hair)(\d+)?/i) || [];
console.log(part);
if (!nr) {
continue;
}
}
let option = document.createElement("option");
option.textContent = mesh.name;
option.value = mesh.id = uuidv4();
indexed[mesh.id] = mesh;
if (mesh.name.toLowerCase().indexOf("hair") !== -1) {
part1.appendChild(option);
}
if (mesh.name.toLowerCase().indexOf("head") !== -1) {
part2.appendChild(option);
}
if (!tree[race]) {
tree[race] = {};
}
if (!tree[race][gender]) {
tree[race][gender] = {'head': [], 'hair': []}
}
tree[race][gender][part.toLowerCase()].push(mesh.id);
}
race.innerHTML = "";
for (let raceX in tree) {
let option = document.createElement("option");
option.textContent = raceX;
option.value = raceX;
race.appendChild(option);
}
race.addEventListener('change', () => {
setRace(race.value);
});
function setRace(name) {
race.value = name;
gender.innerHTML = "";
for (let genderX in tree[name]) {
let option = document.createElement("option");
option.textContent = genderX;
option.value = genderX;
gender.appendChild(option);
}
setGender(gender.value)
}
gender.addEventListener("change", () => {
setGender(gender.value);
});
function setGender(name) {
gender.value = name;
setHair(tree[race.value][name]['hair'][0]);
setHead(tree[race.value][name]['head'][0]);
}
setRace("Dark Elf");
setGender("M");
function getShape(name) {
return new ShapeCollection(indexed[name], "blob/textures/");
}
function setHair(id) {
part1.value = id;
if (shape1 !== null) {
headRenderer.remove(shape1);
}
headRenderer.add(shape1 = getShape(id));
}
part1.addEventListener("change", () => {
setHair(part1.value);
});
prevHair.addEventListener('click', () => {
let hairList = tree[race.value][gender.value]['hair'];
setHair(hairList[(hairList.length + (hairList.indexOf(part1.value) - 1)) % hairList.length]);
});
nextHair.addEventListener('click', () => {
let hairList = tree[race.value][gender.value]['hair'];
setHair(hairList[(hairList.indexOf(part1.value) + 1) % hairList.length]);
});
function setHead(id) {
part2.value = id;
if (shape2 !== null) {
headRenderer.remove(shape2);
}
headRenderer.add(shape2 = getShape(id));
}
part2.addEventListener("change", () => {
setHead(part2.value);
});
prevHead.addEventListener('click', () => {
let headList = tree[race.value][gender.value]['head'];
setHead(headList[(headList.length + (headList.indexOf(part2.value) - 1)) % headList.length]);
});
nextHead.addEventListener('click', () => {
let headList = tree[race.value][gender.value]['head'];
setHead(headList[(headList.indexOf(part2.value) + 1) % headList.length]);
});
headRenderer.draw();
});
window.THREE = THREE;

@ -0,0 +1,6 @@
// I found this on the internet
export function uuidv4() {
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}

@ -0,0 +1,19 @@
const path = require('path');
module.exports = {
entry: './src/index.js',
mode: 'development',
output: {
path: path.resolve(__dirname, 'public/js/dist'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
use: ["source-map-loader"],
enforce: "pre"
}
]
}
};

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save