diff --git a/package.json b/package.json index 26e6cabc..9768153b 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ ], "dependencies": { "array-find": "^0.1.1", - "blizzardry": "^0.3.3", + "blizzardry": "^0.4.0", "bluebird": "^2.10.0", "byte-buffer": "^1.0.3", "classnames": "^2.2.0", @@ -42,29 +42,30 @@ "deep-equal": "^1.0.0", "express": "^4.9.3", "globby": "^5.0.0", + "gsap": "^1.19.0", "inquirer": "^0.8.5", "jsbn": "timkurvers/jsbn.git#wowser", "keymaster": "^1.6.2", "morgan": "^1.3.2", "normalize.css": "^3.0.3", "pngjs": "^2.3.0", - "react": "^0.14.3", - "react-dom": "^0.14.3", - "three": "^0.77.0", + "react": "^15.0.0", + "react-dom": "^15.0.0", + "three": "^0.81.0", "websockify": "^0.7.1" }, "devDependencies": { - "babel-core": "^6.9.1", - "babel-eslint": "^6.0.4", - "babel-loader": "^6.2.4", - "babel-plugin-transform-class-properties": "^6.9.1", + "babel-core": "^6.17.0", + "babel-eslint": "^6.1.0", + "babel-loader": "^6.2.0", + "babel-plugin-transform-class-properties": "^6.9.0", "babel-plugin-transform-export-extensions": "^6.8.0", "babel-plugin-transform-function-bind": "^6.8.0", - "babel-plugin-transform-es2015-block-scoping": "^6.10.1", - "babel-plugin-transform-es2015-modules-commonjs": "^6.8.0", + "babel-plugin-transform-es2015-block-scoping": "^6.15.0", + "babel-plugin-transform-es2015-modules-commonjs": "^6.16.0", "babel-plugin-transform-es2015-parameters": "^6.9.0", "babel-plugin-add-module-exports": "^0.2.1", - "babel-preset-react": "^6.5.0", + "babel-preset-react": "^6.11.0", "chai": "^3.5.0", "codeclimate-test-reporter": "^0.1.0", "css-loader": "^0.23.0", @@ -84,7 +85,7 @@ "gulp-plumber": "^1.1.0", "gulp-remember": "^0.3.0", "gulp-stylus": "^2.4.0", - "html-webpack-plugin": "^2.20.0", + "html-webpack-plugin": "^2.21.0", "istanbul": "^0.4.0", "json-loader": "^0.5.4", "mocha": "^2.5.0", @@ -95,8 +96,8 @@ "style-loader": "^0.13.0", "stylus-loader": "^1.6.1", "url-loader": "^0.5.7", - "webpack": "^1.13.1", - "webpack-dev-server": "^1.14.1", + "webpack": "^1.13.0", + "webpack-dev-server": "^1.14.0", "worker-loader": "^0.7.0" } } diff --git a/src/lib/game/world/doodad-manager.js b/src/lib/game/world/doodad-manager.js index d96fbde3..89548ad2 100644 --- a/src/lib/game/world/doodad-manager.js +++ b/src/lib/game/world/doodad-manager.js @@ -11,8 +11,10 @@ class DoodadManager { // Number of milliseconds to wait before loading another portion of doodads. static LOAD_INTERVAL = 1; - constructor(map) { - this.map = map; + constructor(view, zeropoint) { + this.view = view; + this.zeropoint = zeropoint; + this.chunkRefs = new Map(); this.doodads = new Map(); @@ -177,7 +179,7 @@ class DoodadManager { const doodad = this.doodads.get(entry.id); this.doodads.delete(entry.id); this.animatedDoodads.delete(entry.id); - this.map.remove(doodad); + this.container.remove(doodad); M2Blueprint.unload(doodad); } @@ -185,8 +187,8 @@ class DoodadManager { // Place a doodad on the world map, adhereing to a provided position, rotation, and scale. placeDoodad(doodad, position, rotation, scale) { doodad.position.set( - -(position.z - this.map.constructor.ZEROPOINT), - -(position.x - this.map.constructor.ZEROPOINT), + -(position.z - this.zeropoint), + -(position.x - this.zeropoint), position.y ); @@ -207,11 +209,16 @@ class DoodadManager { } // Add doodad to world map. - this.map.add(doodad); + this.view.add(doodad); doodad.updateMatrix(); + doodad.updateMatrixWorld(); } animate(delta, camera, cameraMoved) { + if (!this.view.visible) { + return; + } + this.animatedDoodads.forEach((doodad) => { if (!doodad.visible) { return; diff --git a/src/lib/game/world/handler.js b/src/lib/game/world/handler.js index 67dd6090..6692dbd1 100644 --- a/src/lib/game/world/handler.js +++ b/src/lib/game/world/handler.js @@ -126,6 +126,10 @@ class WorldHandler extends EventEmitter { this.animateEntities(delta, camera, cameraMoved); if (this.map !== null) { + if (cameraMoved) { + this.map.locateCamera(camera); + } + this.map.animate(delta, camera, cameraMoved); } diff --git a/src/lib/game/world/light/default.js b/src/lib/game/world/light/default.js new file mode 100644 index 00000000..a7dbaa26 --- /dev/null +++ b/src/lib/game/world/light/default.js @@ -0,0 +1,13 @@ +const fogStart = 20.0; +const fogEnd = 200.0; +const fogRange = fogEnd - fogStart; + +const values = { + sunParams: [-0.8, -0.8, -0.8, 0.0], + sunAmbientColor: [ 0.5, 0.5, 0.5, 0.0], + sunDiffuseColor: [ 0.25, 0.5, 1.0, 0.0], + fogParams: [ -(1.0 / (fogRange)), (1.0 / fogRange) * fogEnd, 1.0, 0.0], + fogColor: [ 0.25, 0.5, 1.0, 0.0] +}; + +export default values; diff --git a/src/lib/game/world/light/index.js b/src/lib/game/world/light/index.js new file mode 100644 index 00000000..bd655b1c --- /dev/null +++ b/src/lib/game/world/light/index.js @@ -0,0 +1,497 @@ +import * as THREE from 'three'; + +import DBC from '../../../pipeline/dbc'; +import Default from './default'; +import Tables from './tables'; + +/* + + -- Time of Day Chart -- + + HH:mm - hh:mm - hmin - progress + + 00:00 - 12:00am - 0 - 0.0 + 03:00 - 3:00am - 360 - 0.125 + 06:00 - 6:00am - 720 - 0.25 + 09:00 - 9:00am - 1080 - 0.375 + 12:00 - 12:00pm - 1440 - 0.5 + 15:00 - 3:00pm - 1800 - 0.625 + 18:00 - 6:00pm - 2160 - 0.75 + 21:00 - 9:00pm - 2520 - 0.875 + 24:00 - 12:00am - 2880 - 1.0 + +*/ + +class WorldLight { + + static tables = Tables; + + static overrideTime = null; + + static dayNightProgression = 0.0; + + static sunDirection = { + phi: 0.0, + theta: 0.0, + vector: { + raw: new THREE.Vector3(), + transformed: new THREE.Vector3() + } + }; + + static selfIlluminatedScalar = 0.0; + + static active = { + sources: [], + blend: null + }; + + static uniforms = { + // [dir.x, dir.y, dir.z, unused] + sunParams: new THREE.Uniform(new Float32Array(Default.sunParams)), + + // [r, g, b, a] + sunAmbientColor: new THREE.Uniform(new Float32Array(Default.sunAmbientColor)), + + // [r, g, b, a] + sunDiffuseColor: new THREE.Uniform(new Float32Array(Default.sunDiffuseColor)), + + // [start, end, unused, unused] + fogParams: new THREE.Uniform(new Float32Array(Default.fogParams)), + + // [r, g, b, a] + fogColor: new THREE.Uniform(new Float32Array(Default.fogColor)) + }; + + static update(frame, mapID, time = null) { + const { x, y, z } = frame.camera.position; + + let queryTime; + + if (this.overrideTime !== null) { + queryTime = this.overrideTime; + } else if (time !== null) { + queryTime = time; + } else { + queryTime = this.currentLightTime(); + } + + this.dayNightProgression = queryTime / 2880.0; + + this.updateSunDirection(frame.camera); + + this.updateSelfIlluminatedScalar(); + + this.query(mapID, x, y, z, queryTime).then((results) => { + this.sortLights(results); + + const blend = this.blendLights(results); + + this.updateUniforms(blend); + + this.active.sources = results; + this.active.blend = blend; + }); + } + + /** + * Update the sun direction for the given camera and the current day night progression value. + * + * Note that the actual client seems to transform the sun direction using the view matrix of + * the camera. + * + * In Wowser, we apply lighting in model space, and thus do not make use of the transformed + * direction vector. + * + */ + static updateSunDirection(camera) { + const viewMatrix = camera.matrixWorldInverse; + + const phiTable = this.tables.directionPhiTable; + const thetaTable = this.tables.directionThetaTable; + + const phi = this.interpolateDayNightTable(phiTable, 4, this.dayNightProgression); + const theta = this.interpolateDayNightTable(thetaTable, 4, this.dayNightProgression); + + this.sunDirection.phi = phi; + this.sunDirection.theta = theta; + + const vector = this.getVanillaSunDirection(phi, theta); + const transformedVector = vector.clone().transformDirection(viewMatrix).normalize(); + + this.sunDirection.vector.raw.copy(vector); + this.sunDirection.vector.transformed.copy(transformedVector); + + // Update uniform + this.uniforms.sunParams.value.set([ + vector.x, + vector.y, + vector.z, + ], 0); + } + + static updateSelfIlluminatedScalar() { + const sidnTable = this.tables.sidnTable; + const factor = this.dayNightProgression; + + this.selfIlluminatedScalar = this.interpolateDayNightTable(sidnTable, 4, factor); + } + + static revertUniforms() { + this.uniforms.sunParams.value.set(Default.sunParams, 0); + this.uniforms.sunDiffuseColor.value.set(Default.sunDiffuseColor, 0); + this.uniforms.sunAmbientColor.value.set(Default.sunAmbientColor, 0); + this.uniforms.fogParams.value.set(Default.fogParams, 0); + this.uniforms.fogColor.value.set(Default.fogColor, 0); + } + + static updateUniforms(result) { + this.updateLightUniforms(result); + this.updateFogUniforms(result); + } + + static updateLightUniforms(result) { + // Diffuse Color + + const diffuseColor = result.colors[0]; + + this.uniforms.sunDiffuseColor.value.set([ + diffuseColor[0] / 255.0, + diffuseColor[1] / 255.0, + diffuseColor[2] / 255.0, + diffuseColor[3] / 255.0 + ], 0); + + // Ambient Color + + const ambientColor = result.colors[1]; + + this.uniforms.sunAmbientColor.value.set([ + ambientColor[0] / 255.0, + ambientColor[1] / 255.0, + ambientColor[2] / 255.0, + ambientColor[3] / 255.0 + ], 0); + } + + static updateFogUniforms(result) { + // Fog Params + + const fogEnd = Math.min(result.floats[0] / 36.0, 350.0); + const fogScalar = result.floats[1]; + const fogStart = fogEnd * fogScalar; + const fogRange = fogEnd - fogStart; + + this.uniforms.fogParams.value[0] = -(1.0 / fogRange); + this.uniforms.fogParams.value[1] = (1.0 / fogRange) * fogEnd; + this.uniforms.fogParams.value[2] = 1.0; + this.uniforms.fogParams.value[3] = 0.0; + + // Fog Color + + const fogColor = result.colors[7]; + + this.uniforms.fogColor.value.set([ + fogColor[0] / 255.0, + fogColor[1] / 255.0, + fogColor[2] / 255.0, + fogColor[3] / 255.0 + ], 0); + } + + static blendLights(results) { + return results[0]; + } + + /** + * Returns number of half minutes since midnight. + */ + static currentLightTime() { + const d = new Date(); + + const msSinceMidnight = d.getTime() - d.setHours(0,0,0,0); + + return Math.round(msSinceMidnight / 1000.0 / 30.0); + } + + static query(mapID, x, y, z, time) { + const queryPosition = new THREE.Vector3(x, y, z); + + return DBC.load('Light').then((dbc) => { + const results = []; + + for (const record of dbc.records) { + if (record.mapID !== mapID) { + continue; + } + + const { position, fallOffStart, fallOffEnd } = record; + + const worldPosition = new THREE.Vector3( + 17066.666 - (position.z / 36.0), + 17066.666 - (position.x / 36.0), + position.y / 36.0 + ); + + const distance = worldPosition.distanceTo(queryPosition) * 36.0; + + if (distance > fallOffEnd) { + continue; + } + + let falloff = 0.0; + + if (distance > fallOffStart) { + falloff = (distance - fallOffStart) / (fallOffEnd - fallOffStart); + } + + results.push({ + distance: distance / 36.0, + falloff: falloff, + light: record, + params: null, + colors: [], + floats: [] + }); + } + + if (results.length === 0) { + results.push({ + distance: 0, + falloff: 0, + light: dbc[1], + params: null, + colors: [], + floats: [] + }); + } + + return results; + }).then((results) => { + return DBC.load('LightParams').then((dbc) => { + for (const result of results) { + result.params = dbc[result.light.skyFogID]; + } + + return results; + }); + }).then((results) => { + return DBC.load('LightIntBand').then((dbc) => { + for (const result of results) { + const offset = (result.light.skyFogID * 18) - 17; + const max = offset + 18; + + for (let i = offset; i < max; ++i) { + result.colors.push(this.colorForTime(dbc[i], time)); + } + } + + return results; + }); + }).then((results) => { + return DBC.load('LightFloatBand').then((dbc) => { + for (const result of results) { + const offset = (result.light.skyFogID * 6) - 5; + const max = offset + 6; + + for (let i = offset; i < max; ++i) { + result.floats.push(this.floatForTime(dbc[i], time)); + } + } + + return results; + }); + }); + } + + static colorForTime(table, time) { + return this.interpolateLightTable(table, time, this.lerpVectors, this.bgraIntegerToRGBAVector); + } + + static floatForTime(table, time) { + return this.interpolateLightTable(table, time, this.lerpFloats); + } + + static interpolateLightTable(table, time, lerp, transform = null) { + const { entryCount, times, values } = table; + + if (entryCount === 0) { + return transform ? transform(0) : 0; + } else if (entryCount === 1) { + return transform ? transform(values[0]) : 0; + } + + let v1; + let v2; + let t1; + let t2; + + for (let i = 0; i < entryCount; ++i) { + // Wrap at end + if (i + 1 >= entryCount) { + v1 = values[i]; + v2 = values[0]; + t1 = times[i]; + t2 = times[0] + 2880; + + break; + } + + // Found matching stops + if (times[i] <= time && times[i + 1] >= time) { + v1 = values[i]; + v2 = values[i + 1]; + t1 = times[i]; + t2 = times[i + 1]; + + break; + } + } + + const tdiff = t2 - t1; + + if (tdiff < 0.001) { + return transform ? transform(v1) : v1; + } + + const factor = (time - t1) / tdiff; + + if (transform) { + v1 = transform(v1); + v2 = transform(v2); + } + + return lerp(v1, v2, factor); + } + + static lerpVectors(v1, v2, factor) { + const result = []; + + for (let i = 0, c = v1.length; i < c; ++i) { + result[i] = Math.round(((1.0 - factor) * v1[i]) + (factor * v2[i])); + } + + return result; + } + + static lerpFloats(v1, v2, factor) { + return ((1.0 - factor) * v1) + (factor * v2); + } + + static bgraIntegerToRGBAVector(value) { + const v = []; + + v[0] = (value >> 16) & 0xFF; + v[1] = (value >> 8) & 0xFF; + v[2] = (value >> 0) & 0xFF; + v[3] = (value >> 24) & 0xFF; + + return v; + } + + static sortLights(results) { + results.sort((a, b) => { + if (a.light.fallOffEnd > b.light.fallOffEnd) { + return 1; + } else if (b.light.fallOffEnd > a.light.fallOffEnd) { + return -1; + } else { + return 0; + } + }); + } + + static interpolateDayNightTable(table, size, distance) { + // Clamp + distance = Math.min(Math.max(distance, 0.0), 1.0); + + let d1; + let d2; + let v1; + let v2; + + for (let i = 0; i < size; ++i) { + // Wrap at end + if (i + 1 >= size) { + d1 = table[i * 2]; + d2 = table[0] + 1.0; + + v1 = table[i * 2 + 1]; + v2 = table[0 + 1]; + + break; + } + + // Found matching stops + if (table[i * 2] <= distance && table[(i + 1) * 2] >= distance) { + d1 = table[i * 2]; + d2 = table[(i + 1) * 2]; + + v1 = table[i * 2 + 1]; + v2 = table[(i + 1) * 2 + 1]; + + break; + } + } + + const diff = d2 - d1; + + if (diff < 0.001) { + return v1; + } + + const factor = (distance - d1) / diff; + + return this.lerpFloats(v1, v2, factor); + } + + /** + * Best guess at how the 1.12 client calculated light direction for the sun. + * + * Spherical to cartesian conversion + * + * This function makes use of spherical coordinates, but rendering involves cartesian + * coordinates. The client converts the spherical coordinates represented in the phi and + * theta tables into cartesian coordinates using the approach outlined here: + * + * - https://en.wikipedia.org/wiki/Spherical_coordinate_system#Cartesian_coordinates + * + */ + static getVanillaSunDirection(phi, theta) { + const cartX = Math.sin(theta) * Math.cos(phi); + const cartY = Math.sin(theta) * Math.sin(phi); + const cartZ = Math.cos(theta); + + const dir = new THREE.Vector3(-cartX, cartY, cartZ); + + return dir; + } + + /** + * Light direction for the sun as calculated in the Wrath of the Lich King client. Output + * values have been compared against the actual client for several arbitrary points of time. + * + * This function can be found at offset 7EEA90 in the 3.3.5a client. + * + */ + static getWrathSunDirection(phi, theta) { + const v14 = phi * (1 / Math.PI); + const v4 = v14 - 0.5; + + const v17 = 1.0 - v4 * ((6.0 - 4.0 * v4) * v4); + const v15 = 1.0 - v14 * ((6.0 - 4.0 * v14) * v14); + + const v7 = theta * (1 / Math.PI) + const v8 = v7 - 0.5; + + const v16 = 1.0 - v8 * ((6.0 - 4.0 * v8) * v8); + const v11 = 1.0 - v8 * ((6.0 - 4.0 * v8) * v8); + + const dir = new THREE.Vector3(v11 * v17, v16 * v17, v15); + + return dir; + } + +} + +export default WorldLight; diff --git a/src/lib/game/world/light/tables.js b/src/lib/game/world/light/tables.js new file mode 100644 index 00000000..804bccd0 --- /dev/null +++ b/src/lib/game/world/light/tables.js @@ -0,0 +1,87 @@ +class DayNightTables { + + // DayNight::s_sidnTable + // dword_1A250C0 (15662) + // Self illuminated scalar table + static sidnTable = [ + 0.25, 1.0, + 0.291667, 0.0, + 0.854167, 0.0, + 0.895833, 1.0 + ]; + + // DayNight::DNSky::s_darkTable + // dword_17F3084 (15662) + // Modifies m_highlightSky in light color bands + static skyDarkTable = [ + 0.125, 0.0, + 0.2708333432674408, 1.0, + 0.2916666865348816, 0.0, + 0.8541666269302368, 0.0, + 0.8958333134651184, 1.0, + 0.9993055462837219, 0.0 + ]; + + // DayNight::DNSky::s_fadeTable + // dword_17F30C4 (15662) + static skyFadeTable = [ + 0.125, 1.0, + 0.375, 0.0, + 0.5, -0.5, + 0.625, -0.7, + 0.75, -0.5, + 0.875, 0.0 + ]; + + // DayNight::DNStars::s_fadeTable + // dword_17F3100 (15662) + static starsFadeTable = [ + 0.125, 1.0, + 0.1875, 0.0, + 0.9375, 0.0, + 1.0, 1.0 + ]; + + // DayNight::DNClouds::s_bumpFadeTable + // dword_17F2F80 (15662) + static cloudsBumpFadeTable = [ + 0.1666666716337204, 1.0, + 0.1944444477558136, 1.0, + 0.2013888955116272, 1.0, + 0.2291666716337204, 1.0, + 0.8958333134651184, 1.0, + 0.9236111044883728, 1.0, + 0.8888888955116272, 1.0, + 0.9166666865348816, 1.0 + ]; + + // DayNight::CDayNightObjectInt::SetDirection(void)::phiTable + // dword_1A25280 (15662) + static directionPhiTable = [ + 0.0, 2.2165682315826416, // pi * 0.70555556 + 0.25, 1.919862151145935, // pi * 0.6111111 + 0.5, 2.2165682315826416, // pi * 0.70555556 + 0.75, 1.919862151145935 // pi * 0.6111111 + ]; + + // DayNight::CDayNightObjectInt::SetDirection(void)::thetaTable + // dword_1A25260 (15662) + static directionThetaTable = [ + 0.0, 3.9269907474517822, // pi * 1.25 + 0.25, 3.9269907474517822, // pi * 1.25 + 0.5, 3.9269907474517822, // pi * 1.25 + 0.75, 3.9269907474517822 // pi * 1.25 + ]; + + // DayNight::CDayNightObjectInt::SetPlanets(void)::sunScaleTable + // dword_1A25160 (15662) + static sunScaleTable = [ + 0.25, 2.0, + 0.28125, 1.0, + 0.84375, 1.0, + 0.875, 2.0 + ]; + +} + +export default DayNightTables; diff --git a/src/lib/game/world/location-manager.js b/src/lib/game/world/location-manager.js new file mode 100644 index 00000000..ce139055 --- /dev/null +++ b/src/lib/game/world/location-manager.js @@ -0,0 +1,208 @@ +import THREE from 'three'; + +class LocationManager { + + constructor(map) { + this.map = map; + + this.raycaster = new THREE.Raycaster(); + this.raycastUp = new THREE.Vector3(0, 0, 1); + this.raycastDown = new THREE.Vector3(0, 0, -1); + } + + /** + * Iterate over the set of given cameras, and attempt to identify each camera's location relative + * to map geometry. This location serves as the starting point when traversing WMO groups as + * part of portal culling. + * + * Possible location results: + * - exterior: camera is either not in a WMO, or is in a WMO group marked as exterior + * - interior: camera is in a specific WMO and WMO group, and WMO group is marked as interior + */ + update(cameras) { + for (const camera of cameras) { + this.locateCamera(camera); + } + } + + locateCamera(camera) { + const candidates = []; + + for (const wmo of this.map.wmoManager.entries.values()) { + this.addCandidates(camera, wmo, candidates); + } + + const location = this.selectCandidate(candidates); + + if (location) { + camera.location = location; + } else { + camera.location = { + type: 'exterior' + }; + } + } + + addCandidates(camera, wmo, candidates) { + // The root view needs to have loaded before we can try locate the camera in this WMO + if (!wmo.views.root) { + return; + } + + // All operations assume the camera position is in local space + const cameraLocal = wmo.views.root.worldToLocal(camera.position.clone()); + + // Check if camera could be inside this WMO + const maybeInsideWMO = wmo.root.boundingBox.containsPoint(cameraLocal); + + // Camera cannot be inside this WMO + if (!maybeInsideWMO) { + return; + } + + // Check if camera is in any of this WMO's groups + for (const group of wmo.groups.values()) { + // Only hunting for interior groups + if (group.header.flags & 0x08) { + continue; + } + + // Check if camera could be inside this group + const maybeInsideGroup = group.boundingBox.containsPoint(cameraLocal); + + // Camera cannot be inside this group + if (!maybeInsideGroup) { + continue; + } + + // Query BSP tree for matching leaves + let result = group.bspTree.queryBoundedPoint(cameraLocal, group.boundingBox); + + // Depending on group geometry, interior portions of a group may lack BSP leaves + if (result === null) { + result = { + z: { + min: null, + max: null + } + }; + } + + // Attempt to find unbounded Zs by raycasting the Z axis against portals + if (result.z.min === null || result.z.max === null) { + const portalViews = []; + + for (const portalRef of group.portalRefs) { + const portalView = wmo.views.portals.get(portalRef.portalIndex); + portalViews.push(portalView); + } + + // Unbounded max Z (raycast up to try find portal) + if (result.z.max === null) { + this.raycaster.set(camera.position, this.raycastUp); + const upIntersections = this.raycaster.intersectObjects(portalViews); + + if (upIntersections.length > 0) { + const closestUp = upIntersections[0]; + result.z.max = closestUp.object.worldToLocal(closestUp.point).z; + } + } + + // Unbounded min Z (raycast down to try find portal) + if (result.z.min === null) { + this.raycaster.set(camera.position, this.raycastDown); + const downIntersections = this.raycaster.intersectObjects(portalViews); + + if (downIntersections.length > 0) { + const closestDown = downIntersections[0]; + result.z.min = closestDown.object.worldToLocal(closestDown.point).z; + } + } + } + + const location = { + type: 'interior', + query: result, + camera: { + local: cameraLocal, + world: camera.position + }, + wmo: { + handler: wmo, + root: wmo.root, + group: group, + views: { + root: wmo.views.root, + group: wmo.views.groups.get(group.index) + } + } + }; + + candidates.push(location); + } + } + + selectCandidate(candidates) { + // Adjust bounds and mark invalid candidates + const adjustedCandidates = candidates.map((candidate) => { + const { camera, query } = candidate; + const { group } = candidate.wmo; + + // If a query didn't get a min Z bound from the BSP tree or from raycasting for portals, the + // candidate is invalid. + if (query.z.min === null) { + return null; + } + + // Assume the bounding box max in cases where max Z is unbounded + if (query.z.max === null) { + query.z.max = group.boundingBox.max.z; + } + + const cameraInBoundsZ = + camera.local.z >= query.z.min && + camera.local.z <= query.z.max; + + if (!cameraInBoundsZ) { + return null; + } + + // Get the closest portal within a small range and ensure we're inside it + const closestPortal = group.closestPortal(camera.local, 1.0); + + if (closestPortal !== null) { + const outsidePortal = closestPortal.portalRef.side * closestPortal.distance < 0.0; + + if (outsidePortal) { + return null; + } + } + + return candidate; + }); + + // Remove invalid candidates + const validCandidates = adjustedCandidates.filter((candidate) => candidate !== null); + + // No valid candidates + if (validCandidates.length === 0) { + return null; + } + + // The correct candidate has the highest min Z bound of all remaining candidates + validCandidates.sort((a, b) => { + if (a.query.z.min > b.query.z.min) { + return -1; + } else if (a.query.z.min < b.query.z.min) { + return 1; + } else { + return 0; + } + }); + + return validCandidates[0]; + } + +} + +export default LocationManager; diff --git a/src/lib/game/world/map.js b/src/lib/game/world/map.js index 63ef62d5..7f60d775 100644 --- a/src/lib/game/world/map.js +++ b/src/lib/game/world/map.js @@ -7,6 +7,8 @@ import WDT from '../../pipeline/wdt'; import DoodadManager from './doodad-manager'; import WMOManager from './wmo-manager'; import TerrainManager from './terrain-manager'; +import VisibilityManager from './visibility-manager'; +import LocationManager from './location-manager'; class WorldMap extends THREE.Group { @@ -22,9 +24,17 @@ class WorldMap extends THREE.Group { this.matrixAutoUpdate = false; - this.terrainManager = new TerrainManager(this); - this.doodadManager = new DoodadManager(this); - this.wmoManager = new WMOManager(this); + this.exterior = new THREE.Group(); + this.exterior.name = 'ExteriorView'; + this.add(this.exterior); + + // Set up geometry managers + this.terrainManager = new TerrainManager(this.exterior, this.constructor.ZEROPOINT); + this.doodadManager = new DoodadManager(this.exterior, this.constructor.ZEROPOINT); + this.wmoManager = new WMOManager(this, this.constructor.ZEROPOINT); + + this.visibilityManager = new VisibilityManager(this); + this.locationManager = new LocationManager(this); this.data = data; this.wdt = wdt; @@ -122,6 +132,14 @@ class WorldMap extends THREE.Group { this.wmoManager.animate(delta, camera, cameraMoved); } + locateCamera(camera) { + this.locationManager.update([camera]); + } + + updateVisibility(camera) { + this.visibilityManager.update([camera]); + } + static load(id) { return DBC.load('Map', id).then((data) => { const { internalName: name } = data; diff --git a/src/lib/game/world/terrain-manager.js b/src/lib/game/world/terrain-manager.js index 756a1938..7afe6023 100644 --- a/src/lib/game/world/terrain-manager.js +++ b/src/lib/game/world/terrain-manager.js @@ -1,16 +1,17 @@ class TerrainManager { - constructor(map) { - this.map = map; + constructor(view, zeropoint) { + this.view = view; + this.zeropoint = zeropoint; } loadChunk(_index, terrain) { - this.map.add(terrain); + this.view.add(terrain); terrain.updateMatrix(); } unloadChunk(_index, terrain) { - this.map.remove(terrain); + this.view.remove(terrain); terrain.dispose(); } diff --git a/src/lib/game/world/visibility-manager.js b/src/lib/game/world/visibility-manager.js new file mode 100644 index 00000000..67e5b316 --- /dev/null +++ b/src/lib/game/world/visibility-manager.js @@ -0,0 +1,285 @@ +import THREE from 'three'; + +import THREEUtil from '../../utils/three-util'; + +class VisibilityManager { + + constructor(map) { + this.map = map; + + this.stats = { + wmo: { + visibleGroups: 0, + visibleDoodads: 0 + } + }; + } + + update(cameras) { + if (!this.map) { + return; + } + + // Hide the exterior world (doodads and terrain) until a traversal reaches the exterior + this.map.exterior.visible = false; + + this.hideAllMapDoodads(); + this.hideAllWMOGroups(); + this.hideAllWMODoodads(); + + for (const camera of cameras) { + if (!camera.location) { + continue; + } + + camera.updateMatrix(); + camera.updateMatrixWorld(); + + // Obtain a frustum matching the camera + const frustum = new THREE.Frustum(); + frustum.setFromMatrix(new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse)); + + // Adjust near plane (5) back to camera position + const nearGap = frustum.planes[5].distanceToPoint(camera.position); + frustum.planes[5].constant -= nearGap; + + if (camera.location.type === 'exterior') { + this.enablePortalsFromExterior(0, camera, frustum); + } else { + this.enablePortalsFromInterior(0, camera, frustum); + } + } + + this.updateStats(); + } + + enablePortalsFromExterior(depth, camera, frustum = null, visitedPortals = new Set()) { + this.map.exterior.visible = true; + + for (const doodad of this.map.doodadManager.doodads.values()) { + this.enableStaticObjectInFrustum(doodad, frustum); + } + + const wmos = this.map.wmoManager.entries.values(); + + for (const wmo of wmos) { + const groups = wmo.groups.values(); + + for (const group of groups) { + const isExterior = (group.header.flags & 0x08) !== 0; + + // Only concerned with exterior groups. + if (!isExterior) { + continue; + } + + const view = wmo.views.groups.get(group.index); + + // View could still be pending load. + if (!view) { + continue; + } + + // Cache world-space bounding box on group view + if (!view.worldBoundingBox) { + view.worldBoundingBox = group.boundingBox.clone().applyMatrix4(wmo.views.root.matrixWorld); + } + + // If the current frustum does not include the group view, we can skip it + if (!THREEUtil.frustumContainsBox(frustum, view.worldBoundingBox)) { + continue; + } + + // Since the camera is in the exterior, all exterior WMO groups are visible. + view.visible = true; + + // Doodads within frustum are visible + for (const doodad of wmo.doodadsForGroup(group)) { + this.enableStaticObjectInFrustum(doodad, frustum); + } + + // Traverse inward from the exterior groups of all WMOs, marking any relevant WMO groups + // as visible. + this.traversePortalsAndEnable(depth, camera, wmo, group, frustum, visitedPortals); + } + } + } + + enablePortalsFromInterior(depth, camera, frustum = null, visitedPortals = new Set()) { + const wmo = camera.location.wmo.handler; + const group = camera.location.wmo.group; + const groupView = camera.location.wmo.views.group; + + // The group the camera is currently in should always be visible + groupView.visible = true; + + // Doodads within frustum are visible + for (const doodad of wmo.doodadsForGroup(group)) { + this.enableStaticObjectInFrustum(doodad, frustum); + } + + // Traverse outward from the given group, marking any relevant WMO groups as visible + this.traversePortalsAndEnable(depth, camera, wmo, group, frustum, visitedPortals); + } + + enableStaticObjectInFrustum(object, frustum) { + // Cache world-space bounding box + if (!object.worldBoundingBox) { + object.worldBoundingBox = object.boundingBox.clone().applyMatrix4(object.matrixWorld); + } + + if (THREEUtil.frustumContainsBox(frustum, object.worldBoundingBox)) { + object.visible = true; + } + } + + traversePortalsAndEnable(depth, camera, wmo, group, frustum = null, visitedPortals = new Set()) { + const view = wmo.views.groups.get(group.index); + + const cameraLocal = view.worldToLocal(camera.position.clone()); + + // Doodads within frustum are visible + for (const doodad of wmo.doodadsForGroup(group)) { + this.enableStaticObjectInFrustum(doodad, frustum); + } + + for (let pindex = 0, pcount = group.portals.length; pindex < pcount; ++pindex) { + const portal = group.portals[pindex]; + const ref = group.portalRefs[pindex]; + const destination = wmo.groups.get(ref.groupIndex); + + // Destination group is pending load + if (!destination) { + continue; + } + + const portalView = wmo.views.portals.get(ref.portalIndex); + const destinationView = wmo.views.groups.get(destination.index); + const exteriorDestination = (destination.header.flags & 0x08) !== 0; + + // Destination group's view is pending load + if (!destinationView) { + continue; + } + + // Already visited this portal, so we're done + if (visitedPortals.has(portalView)) { + continue; + } + + // Exterior to exterior links are already covered by enablePortalsFromExterior + if ((group.header.flags & 0x08) !== 0 && exteriorDestination) { + continue; + } + + const distance = portal.plane.distanceToPoint(cameraLocal) * ref.side + 0.001; + const insidePortal = distance < 0.0; + + // Portals must be traversed outward + if (insidePortal) { + continue; + } + + // Portal out of group is not visible from previous frustum + if (frustum !== null && !portalView.intersectFrustum(frustum)) { + continue; + } + + // Portal out of group is visible, thus the destination group is visible + destinationView.visible = true; + + // Track visited portals to prevent duplicate work + visitedPortals.add(portalView); + + // Project a frustum out of this portal for use in the next level of recursion + const nextFrustum = portalView.createFrustum(camera, frustum, ref.side < 0); + + if (!nextFrustum) { + continue; + } + + // Portal out of group is to exterior and camera is not already in exterior, thus we need + // to traverse and enable exterior groups + if (exteriorDestination && camera.location.type !== 'exterior') { + this.enablePortalsFromExterior(depth + 1, camera, nextFrustum, visitedPortals); + } + + // Recurse + this.traversePortalsAndEnable(depth + 1, camera, wmo, destination, nextFrustum, visitedPortals); + } + } + + hideAllWMOGroups() { + const wmos = this.map.wmoManager.entries.values(); + + for (const wmo of wmos) { + const groups = wmo.groups.values(); + + for (const group of groups) { + const view = wmo.views.groups.get(group.index); + + // View can be pending load + if (!view) { + continue; + } + + view.visible = false; + } + } + } + + hideAllWMODoodads() { + const wmos = this.map.wmoManager.entries.values(); + + for (const wmo of wmos) { + const doodads = wmo.doodads.values(); + + for (const doodad of doodads) { + doodad.visible = false; + } + } + } + + hideAllMapDoodads() { + for (const doodad of this.map.doodadManager.doodads.values()) { + doodad.visible = false; + } + } + + updateStats() { + let visibleGroupCount = 0; + let visibleDoodadCount = 0; + + const wmos = this.map.wmoManager.entries.values(); + + for (const wmo of wmos) { + const groups = wmo.groups.values(); + const doodads = wmo.doodads.values(); + + for (const group of groups) { + const view = wmo.views.groups.get(group.index); + + // View can be pending load + if (!view) { + continue; + } + + if (view.visible) { + visibleGroupCount++; + } + } + + for (const doodad of doodads) { + if (doodad.visible) { + visibleDoodadCount++; + } + } + } + + this.stats.wmo.visibleGroups = visibleGroupCount; + this.stats.wmo.visibleDoodads = visibleDoodadCount; + } + +} + +export default VisibilityManager; diff --git a/src/lib/game/world/wmo-manager/index.js b/src/lib/game/world/wmo-manager.js similarity index 56% rename from src/lib/game/world/wmo-manager/index.js rename to src/lib/game/world/wmo-manager.js index c857ede5..0ff50c91 100644 --- a/src/lib/game/world/wmo-manager/index.js +++ b/src/lib/game/world/wmo-manager.js @@ -1,6 +1,5 @@ -import ContentQueue from '../content-queue'; -import WMOHandler from './wmo-handler'; -import WMOBlueprint from '../../../pipeline/wmo/blueprint'; +import ContentQueue from '../../utils/content-queue'; +import WMO from '../../pipeline/wmo'; class WMOManager { @@ -10,8 +9,9 @@ class WMOManager { static UNLOAD_DELAY_INTERVAL = 30000; - constructor(map) { - this.map = map; + constructor(view, zeropoint) { + this.view = view; + this.zeropoint = zeropoint; this.chunkRefs = new Map(); @@ -27,6 +27,8 @@ class WMOManager { this.entries = new Map(); + this.pendingUnloads = new Map(); + this.queues = { loadEntry: new ContentQueue( ::this.processLoadEntry, @@ -124,41 +126,99 @@ class WMOManager { this.counters.loadingEntries--; } - scheduleUnloadEntry(wmoEntry) { - const wmoHandler = this.entries.get(wmoEntry.id); + scheduleUnloadEntry(entry) { + const wmo = this.entries.get(entry.id); + + if (!wmo) { + return; + } - if (!wmoHandler) { + if (this.pendingUnloads.has(entry.id)) { return; } - wmoHandler.scheduleUnload(this.constructor.UNLOAD_DELAY_INTERVAL); + const unload = () => { + this.unloadEntry(entry); + }; + + this.pendingUnloads.set(entry.id, setTimeout(unload, this.constructor.UNLOAD_DELAY_INTERVAL)); } - cancelUnloadEntry(wmoEntry) { - const wmoHandler = this.entries.get(wmoEntry.id); + cancelUnloadEntry(entry) { + const wmo = this.entries.get(entry.id); - if (!wmoHandler) { + if (!wmo) { return; } - wmoHandler.cancelUnload(); + if (this.pendingUnloads.has(entry.id)) { + return; + } + + clearTimeout(this.pendingUnloads.get(entry.id)); + } + + unloadEntry(entry) { + this.pendingUnloads.delete(entry.id); + + const wmo = this.entries.get(entry.id); + + this.view.remove(wmo.views.root); + + this.entries.delete(entry.id); + this.counters.loadedEntries--; + + this.counters.loadingGroups -= wmo.counters.loadingGroups; + this.counters.loadedGroups -= wmo.counters.loadedGroups; + this.counters.loadingDoodads -= wmo.counters.loadingDoodads; + this.counters.loadedDoodads -= wmo.counters.loadedDoodads; + this.counters.animatedDoodads -= wmo.counters.animatedDoodads; + + wmo.unload(); } - processLoadEntry(wmoEntry) { - const wmoHandler = new WMOHandler(this, wmoEntry); - this.entries.set(wmoEntry.id, wmoHandler); + processLoadEntry(entry) { + const wmo = new WMO(entry.filename, entry.doodadSet, entry.id, this.counters); + + this.entries.set(entry.id, wmo); - WMOBlueprint.load(wmoEntry.filename).then((wmoRoot) => { - wmoHandler.load(wmoRoot); + wmo.load().then(() => { + this.placeWMOView(entry, wmo.views.root); this.counters.loadingEntries--; this.counters.loadedEntries++; }); } + placeWMOView(entry, view) { + const { position, rotation } = entry; + + view.position.set( + -(position.z - this.zeropoint), + -(position.x - this.zeropoint), + position.y + ); + + // Provided as (Z, X, -Y) + view.rotation.set( + rotation.z * Math.PI / 180, + rotation.x * Math.PI / 180, + -rotation.y * Math.PI / 180 + ); + + // Adjust WMO rotation to match Wowser's axes. + const quat = view.quaternion; + quat.set(quat.x, quat.y, quat.z, -quat.w); + + view.updateMatrix(); + view.updateMatrixWorld(); + + this.view.add(view); + } + animate(delta, camera, cameraMoved) { - this.entries.forEach((wmoHandler) => { - wmoHandler.animate(delta, camera, cameraMoved); + this.entries.forEach((wmo) => { + wmo.animate(delta, camera, cameraMoved); }); } diff --git a/src/lib/game/world/wmo-manager/wmo-handler.js b/src/lib/game/world/wmo-manager/wmo-handler.js deleted file mode 100644 index f44e4ed0..00000000 --- a/src/lib/game/world/wmo-manager/wmo-handler.js +++ /dev/null @@ -1,401 +0,0 @@ -import ContentQueue from '../content-queue'; -import WMOBlueprint from '../../../pipeline/wmo/blueprint'; -import WMOGroupBlueprint from '../../../pipeline/wmo/group/blueprint'; -import M2Blueprint from '../../../pipeline/m2/blueprint'; - -class WMOHandler { - - static LOAD_GROUP_INTERVAL = 1; - static LOAD_GROUP_WORK_FACTOR = 1 / 10; - static LOAD_GROUP_WORK_MIN = 2; - - static LOAD_DOODAD_INTERVAL = 1; - static LOAD_DOODAD_WORK_FACTOR = 1 / 20; - static LOAD_DOODAD_WORK_MIN = 2; - - constructor(manager, entry) { - this.manager = manager; - this.entry = entry; - this.root = null; - - this.groups = new Map(); - this.doodads = new Map(); - this.animatedDoodads = new Map(); - - this.doodadSet = []; - - this.doodadRefs = new Map(); - - this.counters = { - loadingGroups: 0, - loadingDoodads: 0, - loadedGroups: 0, - loadedDoodads: 0, - animatedDoodads: 0 - }; - - this.queues = { - loadGroup: new ContentQueue( - ::this.processLoadGroup, - this.constructor.LOAD_GROUP_INTERVAL, - this.constructor.LOAD_GROUP_WORK_FACTOR, - this.constructor.LOAD_GROUP_WORK_MIN - ), - - loadDoodad: new ContentQueue( - ::this.processLoadDoodad, - this.constructor.LOAD_DOODAD_INTERVAL, - this.constructor.LOAD_DOODAD_WORK_FACTOR, - this.constructor.LOAD_DOODAD_WORK_MIN - ) - }; - - this.pendingUnload = null; - this.unloading = false; - } - - load(wmoRoot) { - this.root = wmoRoot; - - this.doodadSet = this.root.doodadSet(this.entry.doodadSet); - - this.placeRoot(); - - this.enqueueLoadGroups(); - } - - enqueueLoadGroups() { - const outdoorGroupIDs = this.root.outdoorGroupIDs; - const indoorGroupIDs = this.root.indoorGroupIDs; - - for (let ogi = 0, oglen = outdoorGroupIDs.length; ogi < oglen; ++ogi) { - const wmoGroupID = outdoorGroupIDs[ogi]; - this.enqueueLoadGroup(wmoGroupID); - } - - for (let igi = 0, iglen = indoorGroupIDs.length; igi < iglen; ++igi) { - const wmoGroupID = indoorGroupIDs[igi]; - this.enqueueLoadGroup(wmoGroupID); - } - } - - enqueueLoadGroup(wmoGroupID) { - // Already loaded. - if (this.groups.has(wmoGroupID)) { - return; - } - - this.queues.loadGroup.add(wmoGroupID, wmoGroupID); - - this.manager.counters.loadingGroups++; - this.counters.loadingGroups++; - } - - processLoadGroup(wmoGroupID) { - // Already loaded. - if (this.groups.has(wmoGroupID)) { - this.manager.counters.loadingGroups--; - this.counters.loadingGroups--; - return; - } - - WMOGroupBlueprint.loadWithID(this.root, wmoGroupID).then((wmoGroup) => { - if (this.unloading) { - return; - } - - this.loadGroup(wmoGroupID, wmoGroup); - - this.manager.counters.loadingGroups--; - this.counters.loadingGroups--; - this.manager.counters.loadedGroups++; - this.counters.loadedGroups++; - }); - } - - loadGroup(wmoGroupID, wmoGroup) { - this.placeGroup(wmoGroup); - - this.groups.set(wmoGroupID, wmoGroup); - - if (wmoGroup.data.MODR) { - this.enqueueLoadGroupDoodads(wmoGroup); - } - } - - enqueueLoadGroupDoodads(wmoGroup) { - wmoGroup.data.MODR.doodadIndices.forEach((doodadIndex) => { - const wmoDoodadEntry = this.doodadSet[doodadIndex]; - - // Since the doodad set is filtered based on the requested set in the entry, not all - // doodads referenced by a group will be present. - if (!wmoDoodadEntry) { - return; - } - - // Assign the index as an id property on the entry. - wmoDoodadEntry.id = doodadIndex; - - const refCount = this.addDoodadRef(wmoDoodadEntry, wmoGroup); - - // Only enqueue load on the first reference, since it'll already have been enqueued on - // subsequent references. - if (refCount === 1) { - this.enqueueLoadDoodad(wmoDoodadEntry); - } - }); - } - - enqueueLoadDoodad(wmoDoodadEntry) { - // Already loading or loaded. - if (this.queues.loadDoodad.has(wmoDoodadEntry.id) || this.doodads.has(wmoDoodadEntry.id)) { - return; - } - - this.queues.loadDoodad.add(wmoDoodadEntry.id, wmoDoodadEntry); - - this.manager.counters.loadingDoodads++; - this.counters.loadingDoodads++; - } - - processLoadDoodad(wmoDoodadEntry) { - // Already loaded. - if (this.doodads.has(wmoDoodadEntry.id)) { - this.manager.counters.loadingDoodads--; - this.counters.loadingDoodads--; - return; - } - - M2Blueprint.load(wmoDoodadEntry.filename).then((wmoDoodad) => { - if (this.unloading) { - return; - } - - this.loadDoodad(wmoDoodadEntry, wmoDoodad); - - this.manager.counters.loadingDoodads--; - this.counters.loadingDoodads--; - this.manager.counters.loadedDoodads++; - this.counters.loadedDoodads++; - - if (wmoDoodad.animated) { - this.manager.counters.animatedDoodads++; - this.counters.animatedDoodads++; - } - }); - } - - loadDoodad(wmoDoodadEntry, wmoDoodad) { - wmoDoodad.entryID = wmoDoodadEntry.id; - - this.placeDoodad(wmoDoodadEntry, wmoDoodad); - - if (wmoDoodad.animated) { - this.animatedDoodads.set(wmoDoodadEntry.id, wmoDoodad); - - if (wmoDoodad.animations.length > 0) { - // TODO: Do WMO doodads have more than one animation? If so, which one should play? - wmoDoodad.animations.playAnimation(0); - wmoDoodad.animations.playAllSequences(); - } - } - - this.doodads.set(wmoDoodadEntry.id, wmoDoodad); - } - - scheduleUnload(unloadDelay = 0) { - this.pendingUnload = setTimeout(::this.unload, unloadDelay); - } - - cancelUnload() { - if (this.pendingUnload) { - clearTimeout(this.pendingUnload); - } - } - - unload() { - this.unloading = true; - - this.manager.entries.delete(this.entry.id); - this.manager.counters.loadedEntries--; - - this.queues.loadGroup.clear(); - this.queues.loadDoodad.clear(); - - this.manager.counters.loadingGroups -= this.counters.loadingGroups; - this.manager.counters.loadedGroups -= this.counters.loadedGroups; - this.manager.counters.loadingDoodads -= this.counters.loadingDoodads; - this.manager.counters.loadedDoodads -= this.counters.loadedDoodads; - this.manager.counters.animatedDoodads -= this.counters.animatedDoodads; - - this.counters.loadingGroups = 0; - this.counters.loadedGroups = 0; - this.counters.loadingDoodads = 0; - this.counters.loadedDoodads = 0; - this.counters.animatedDoodads = 0; - - this.manager.map.remove(this.root); - - for (const wmoGroup of this.groups.values()) { - this.root.remove(wmoGroup); - WMOGroupBlueprint.unload(wmoGroup); - } - - for (const wmoDoodad of this.doodads.values()) { - this.root.remove(wmoDoodad); - M2Blueprint.unload(wmoDoodad); - } - - WMOBlueprint.unload(this.root); - - this.groups = new Map(); - this.doodads = new Map(); - this.animatedDoodads = new Map(); - this.doodadRefs = new Map(); - - this.root = null; - this.entry = null; - } - - placeRoot() { - const { position, rotation } = this.entry; - - this.root.position.set( - -(position.z - this.manager.map.constructor.ZEROPOINT), - -(position.x - this.manager.map.constructor.ZEROPOINT), - position.y - ); - - // Provided as (Z, X, -Y) - this.root.rotation.set( - rotation.z * Math.PI / 180, - rotation.x * Math.PI / 180, - -rotation.y * Math.PI / 180 - ); - - // Adjust WMO rotation to match Wowser's axes. - const quat = this.root.quaternion; - quat.set(quat.x, quat.y, quat.z, -quat.w); - - this.manager.map.add(this.root); - this.root.updateMatrix(); - } - - placeGroup(wmoGroup) { - this.root.add(wmoGroup); - wmoGroup.updateMatrix(); - } - - placeDoodad(wmoDoodadEntry, wmoDoodad) { - const { position, rotation, scale } = wmoDoodadEntry; - - wmoDoodad.position.set(-position.x, -position.y, position.z); - - // Adjust doodad rotation to match Wowser's axes. - const quat = wmoDoodad.quaternion; - quat.set(rotation.x, rotation.y, -rotation.z, -rotation.w); - - wmoDoodad.scale.set(scale, scale, scale); - - this.root.add(wmoDoodad); - wmoDoodad.updateMatrix(); - } - - addDoodadRef(wmoDoodadEntry, wmoGroup) { - const key = wmoDoodadEntry.id; - - let doodadRefs; - - // Fetch or create group references for doodad. - if (this.doodadRefs.has(key)) { - doodadRefs = this.doodadRefs.get(key); - } else { - doodadRefs = new Set(); - this.doodadRefs.set(key, doodadRefs); - } - - // Add group reference to doodad. - doodadRefs.add(wmoGroup.groupID); - - const refCount = doodadRefs.size; - - return refCount; - } - - removeDoodadRef(wmoDoodadEntry, wmoGroup) { - const key = wmoDoodadEntry.id; - - const doodadRefs = this.doodadRefs.get(key); - - if (!doodadRefs) { - return 0; - } - - // Remove group reference for doodad. - doodadRefs.delete(wmoGroup.groupID); - - const refCount = doodadRefs.size; - - if (doodadRefs.size === 0) { - this.doodadRefs.delete(key); - } - - return refCount; - } - - groupsForDoodad(wmoDoodad) { - const wmoGroupIDs = this.doodadRefs.get(wmoDoodad.entryID); - const wmoGroups = []; - - for (const wmoGroupID of wmoGroupIDs) { - const wmoGroup = this.groups.get(wmoGroupID); - - if (wmoGroup) { - wmoGroups.push(wmoGroup); - } - } - - return wmoGroups; - } - - doodadsForGroup(wmoGroup) { - const wmoDoodads = []; - - for (const refs of this.doodadRefs) { - const [wmoDoodadEntryID, wmoGroupIDs] = refs; - - if (wmoGroupIDs.has(wmoGroup.groupID)) { - const wmoDoodad = this.doodads.get(wmoDoodadEntryID); - - if (wmoDoodad) { - wmoDoodads.push(wmoDoodad); - } - } - } - - return wmoDoodads; - } - - animate(delta, camera, cameraMoved) { - for (const wmoDoodad of this.animatedDoodads.values()) { - if (!wmoDoodad.visible) { - continue; - } - - if (wmoDoodad.receivesAnimationUpdates && wmoDoodad.animations.length > 0) { - wmoDoodad.animations.update(delta); - } - - if (cameraMoved && wmoDoodad.billboards.length > 0) { - wmoDoodad.applyBillboards(camera); - } - - if (wmoDoodad.skeletonHelper) { - wmoDoodad.skeletonHelper.update(); - } - } - } - -} - -export default WMOHandler; diff --git a/src/lib/pipeline/adt/index.js b/src/lib/pipeline/adt/index.js index 46465d74..cdd6f63a 100644 --- a/src/lib/pipeline/adt/index.js +++ b/src/lib/pipeline/adt/index.js @@ -50,7 +50,7 @@ class ADT { static load(path, wdtFlags) { if (!(path in this.cache)) { this.cache[path] = WorkerPool.enqueue('ADT', path, wdtFlags).then((args) => { - const [data] = args; + const data = args; return new this(path, data); }); } diff --git a/src/lib/pipeline/dbc/index.js b/src/lib/pipeline/dbc/index.js index 559286a5..90472d7c 100644 --- a/src/lib/pipeline/dbc/index.js +++ b/src/lib/pipeline/dbc/index.js @@ -22,7 +22,7 @@ class DBC { static load(name, id) { if (!(name in this.cache)) { this.cache[name] = WorkerPool.enqueue('DBC', name).then((args) => { - const [data] = args; + const data = args; return new this(data); }); } diff --git a/src/lib/pipeline/texture-loader.js b/src/lib/pipeline/texture-loader.js index 39b2abcc..0672edc5 100644 --- a/src/lib/pipeline/texture-loader.js +++ b/src/lib/pipeline/texture-loader.js @@ -15,7 +15,7 @@ class TextureLoader { const path = rawPath.toUpperCase(); // Ensure we cache based on texture settings. Some textures are reused with different settings. - const textureKey = `${path};ws:${wrapS.toString()};wt:${wrapT.toString()};fy:${flipY}}`; + const textureKey = `${path};ws:${wrapS.toString()};wt:${wrapT.toString()};fy:${flipY}`; // Prevent unintended unloading. if (this.pendingUnload.has(textureKey)) { diff --git a/src/lib/pipeline/wdt/index.js b/src/lib/pipeline/wdt/index.js index 16bfde92..9efced7c 100644 --- a/src/lib/pipeline/wdt/index.js +++ b/src/lib/pipeline/wdt/index.js @@ -11,7 +11,7 @@ class WDT { static load(path) { if (!(path in this.cache)) { this.cache[path] = WorkerPool.enqueue('WDT', path).then((args) => { - const [data] = args; + const data = args; return new this(data); }); } diff --git a/src/lib/pipeline/wmo/group/blueprint.js b/src/lib/pipeline/wmo/group/blueprint.js deleted file mode 100644 index c8dfd55f..00000000 --- a/src/lib/pipeline/wmo/group/blueprint.js +++ /dev/null @@ -1,86 +0,0 @@ -import WorkerPool from '../../worker/pool'; -import WMOGroup from './'; - -class WMOGroupBlueprint { - - static cache = new Map(); - - static references = new Map(); - static pendingUnload = new Set(); - static unloaderRunning = false; - - static UNLOAD_INTERVAL = 15000; - - static load(wmo, id, rawPath) { - const path = rawPath.toUpperCase(); - - // Prevent unintended unloading. - if (this.pendingUnload.has(path)) { - this.pendingUnload.delete(path); - } - - // Background unloader might need to be started. - if (!this.unloaderRunning) { - this.unloaderRunning = true; - this.backgroundUnload(); - } - - // Keep track of references. - let refCount = this.references.get(path) || 0; - ++refCount; - this.references.set(path, refCount); - - if (!this.cache.has(path)) { - this.cache.set(path, WorkerPool.enqueue('WMOGroup', path).then((args) => { - const [data] = args; - - return new WMOGroup(wmo, id, data, path); - })); - } - - return this.cache.get(path).then((wmoGroup) => { - return wmoGroup.clone(); - }); - } - - static loadWithID(wmo, id) { - const suffix = `000${id}`.slice(-3); - const groupPath = wmo.path.replace(/\.wmo/i, `_${suffix}.wmo`); - - return this.load(wmo, id, groupPath); - } - - static unload(wmoGroup) { - wmoGroup.dispose(); - - const path = wmoGroup.path.toUpperCase(); - - let refCount = this.references.get(path) || 1; - --refCount; - - if (refCount === 0) { - this.pendingUnload.add(path); - } else { - this.references.set(path, refCount); - } - } - - static backgroundUnload() { - this.pendingUnload.forEach((path) => { - if (this.cache.has(path)) { - this.cache.get(path).then((wmoGroup) => { - wmoGroup.dispose(); - }); - } - - this.cache.delete(path); - this.references.delete(path); - this.pendingUnload.delete(path); - }); - - setTimeout(this.backgroundUnload.bind(this), this.UNLOAD_INTERVAL); - } - -} - -export default WMOGroupBlueprint; diff --git a/src/lib/pipeline/wmo/group/index.js b/src/lib/pipeline/wmo/group/index.js index a70f32db..01585ce8 100644 --- a/src/lib/pipeline/wmo/group/index.js +++ b/src/lib/pipeline/wmo/group/index.js @@ -1,158 +1,238 @@ -import THREE from 'three'; - -import WMOMaterial from '../material'; - -class WMOGroup extends THREE.Mesh { - - static cache = {}; - - constructor(wmo, id, data, path) { - super(); - - this.dispose = ::this.dispose; - - this.matrixAutoUpdate = false; - - this.wmo = wmo; - this.groupID = id; - this.data = data; - this.path = path; - - this.indoor = data.indoor; - this.animated = false; - - const vertexCount = data.MOVT.vertices.length; - const textureCoords = data.MOTV.textureCoords; - - const positions = new Float32Array(vertexCount * 3); - const normals = new Float32Array(vertexCount * 3); - const uvs = new Float32Array(vertexCount * 2); - const colors = new Float32Array(vertexCount * 3); - const alphas = new Float32Array(vertexCount); - - data.MOVT.vertices.forEach(function(vertex, index) { - // Provided as (X, Z, -Y) - positions[index * 3] = vertex[0]; - positions[index * 3 + 1] = vertex[2]; - positions[index * 3 + 2] = -vertex[1]; - - uvs[index * 2] = textureCoords[index][0]; - uvs[index * 2 + 1] = textureCoords[index][1]; - }); - - data.MONR.normals.forEach(function(normal, index) { - normals[index * 3] = normal[0]; - normals[index * 3 + 1] = normal[2]; - normals[index * 3 + 2] = -normal[1]; - }); - - if ('MOCV' in data) { - data.MOCV.colors.forEach(function(color, index) { - colors[index * 3] = color.r / 255.0; - colors[index * 3 + 1] = color.g / 255.0; - colors[index * 3 + 2] = color.b / 255.0; - alphas[index] = color.a / 255.0; - }); - } else if (this.indoor) { - // Default indoor vertex color: rgba(0.5, 0.5, 0.5, 1.0) - data.MOVT.vertices.forEach(function(_vertex, index) { - colors[index * 3] = 127.0 / 255.0; - colors[index * 3 + 1] = 127.0 / 255.0; - colors[index * 3 + 2] = 127.0 / 255.0; - alphas[index] = 1.0; - }); +import * as THREE from 'three'; + +import WMORootFlags from '../root/flags'; +import WMOGroupView from './view'; +import BSPTree from '../../../utils/bsp-tree'; + +class WMOGroup { + + constructor(root, def) { + this.root = root; + + this.path = def.path; + this.index = def.index; + this.id = def.groupID; + this.header = def.header; + + this.doodadRefs = def.doodadRefs; + + this.createPortals(root, def); + this.createMaterial(def.materialRefs); + this.attenuateVertexColors(root, def.attributes, def.batches); + this.createGeometry(def.attributes, def.batches); + this.createBoundingBox(def.boundingBox); + this.createBSPTree(def.bspNodes, def.bspPlaneIndices, def.attributes); + } + + // Produce a new WMOGroupView suitable for placement in a scene. + createView() { + return new WMOGroupView(this, this.geometry, this.material); + } + + createPortals(root, def) { + const portals = this.portals = []; + const portalRefs = this.portalRefs = []; + + if (def.header.portalCount > 0) { + const pbegin = def.header.portalOffset; + const pend = pbegin + def.header.portalCount; + + for (let pindex = pbegin; pindex < pend; ++pindex) { + const ref = root.portalRefs[pindex]; + const portal = root.portals[ref.portalIndex]; + + portalRefs.push(ref); + portals.push(portal); + } } + } - const indices = new Uint32Array(data.MOVI.triangles); + // Materials are created on the root blueprint to take advantage of sharing materials across + // multiple groups (when possible). + createMaterial(materialRefs) { + const material = this.material = new THREE.MultiMaterial(); + material.materials = this.root.loadMaterials(materialRefs); + } + createGeometry(attributes, batches) { const geometry = this.geometry = new THREE.BufferGeometry(); - geometry.setIndex(new THREE.BufferAttribute(indices, 1)); + + const { indices, positions, normals, uvs, colors } = attributes; + geometry.addAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.addAttribute('normal', new THREE.BufferAttribute(normals, 3)); geometry.addAttribute('uv', new THREE.BufferAttribute(uvs, 2)); + geometry.addAttribute('acolor', new THREE.BufferAttribute(colors, 4)); + + geometry.setIndex(new THREE.BufferAttribute(indices, 1)); - // TODO: Perhaps it is possible to directly use a vec4 here? Currently, color + alpha is - // combined into a vec4 in the material's vertex shader. For some reason, attempting to - // directly use a BufferAttribute with a length of 4 resulted in incorrect ordering for the - // values in the shader. - geometry.addAttribute('color', new THREE.BufferAttribute(colors, 3)); - geometry.addAttribute('alpha', new THREE.BufferAttribute(alphas, 1)); + this.assignBatches(geometry, batches); - // Mirror geometry over X and Y axes and rotate - const matrix = new THREE.Matrix4(); - matrix.makeScale(-1, -1, 1); - geometry.applyMatrix(matrix); - geometry.rotateX(-Math.PI / 2); + return geometry; + } - const materialIDs = []; + assignBatches(geometry, batches) { + const batchCount = batches.length; - data.MOBA.batches.forEach(function(batch) { - materialIDs.push(batch.materialID); - geometry.addGroup(batch.firstIndex, batch.indexCount, batch.materialID); - }); + for (let index = 0; index < batchCount; ++index) { + const batch = batches[index]; + geometry.addGroup(batch.firstIndex, batch.indexCount, index); + } + } - const materialDefs = this.wmo.data.MOMT.materials; - const texturePaths = this.wmo.data.MOTX.filenames; + dispose() { + if (this.geometry) { + this.geometry.dispose(); + } - this.material = this.createMultiMaterial(materialIDs, materialDefs, texturePaths); + if (this.material) { + for (const material of this.material.materials) { + this.root.unloadMaterial(material); + } + } } - createMultiMaterial(materialIDs, materialDefs, texturePaths) { - const multiMaterial = new THREE.MultiMaterial(); + createBoundingBox(def) { + const boundingBox = this.boundingBox = new THREE.Box3; - materialIDs.forEach((materialID) => { - const materialDef = materialDefs[materialID]; + const min = new THREE.Vector3(def.min[0], def.min[1], def.min[2]); + const max = new THREE.Vector3(def.max[0], def.max[1], def.max[2]); - if (this.indoor) { - materialDef.indoor = true; - } else { - materialDef.indoor = false; + boundingBox.set(min, max); + } + + createBSPTree(nodes, planeIndices, attributes) { + const { indices, positions } = attributes; + + const bspTree = this.bspTree = new BSPTree(nodes, planeIndices, indices, positions); + } + + /** + * Identify the closest portal to the given point (in local space). Projects point on portal + * plane and clamps to portal vertex bounds prior to calculating distance. + * + * See: CMapObj::ClosestPortal + * + * @param point - Point (in local space) for which distance is calculated + * @param max - Optional upper limit for distance + * + * @returns - Closest portal and corresponding ref + * + */ + closestPortal(point, max = null) { + if (this.portals.length === 0) { + return null; + } + + let shortestDistance = max; + + const result = { + portal: null, + portalRef: null, + distance: null + }; + + for (let index = 0, count = this.portals.length; index < count; ++index) { + const portal = this.portals[index]; + const portalRef = this.portalRefs[index]; + + const distance = portal.plane.projectPoint(point). + clamp(portal.boundingBox.min, portal.boundingBox.max). + distanceTo(point); + + if (shortestDistance === null || distance < shortestDistance) { + shortestDistance = distance; + + const sign = portal.plane.distanceToPoint(point) < 0.0 ? -1 : 1; + + result.portal = portal; + result.portalRef = portalRef; + result.distance = distance * sign; } + } - if (!this.wmo.data.MOHD.skipBaseColor) { - materialDef.useBaseColor = true; - materialDef.baseColor = this.wmo.data.MOHD.baseColor; - } else { - materialDef.useBaseColor = false; + return (result.portal === null) ? null : result; + } + + attenuateVertexColors(root, attributes, batches) { + if (root.header.flags & WMORootFlags.SKIP_MOCV_ATTENUATION) { + return; + } + + const { batchCounts, batchOffsets } = this.header; + + if (batchCounts.a === 0) { + return; + } + + const firstBatchB = batches[batchOffsets.b]; + + const vertices = attributes.positions; + const colors = attributes.colors; + + const vmax = firstBatchB ? firstBatchB.firstVertex : vertices.length; + + for (let vindex = 0; vindex < vmax; ++vindex) { + const color = colors.subarray(vindex * 4, vindex * 4 + 4); + const vertex = vertices.subarray(vindex * 3, vindex * 3 + 3); + + // In the case of no portals, there is no world light + if (this.portals.length === 0) { + color[3] = 0.0; + continue; } - const material = this.createMaterial(materialDefs[materialID], texturePaths); + const origin = new THREE.Vector3(vertex[0], vertex[1], vertex[2]); + const closestPortal = this.closestPortal(origin, 6.0); + + if (!closestPortal) { + color[3] = 0.0; + continue; + } - multiMaterial.materials[materialID] = material; - }); + let attenuation = 0.0; + let newAlpha = 0.0; - return multiMaterial; - } + const distance = closestPortal.distance; - createMaterial(materialDef, texturePaths) { - const textureDefs = []; + const destinationFlags = root.groupInfo[closestPortal.portalRef.groupIndex].flags; - materialDef.textures.forEach((textureDef) => { - const texturePath = texturePaths[textureDef.offset]; + if (destinationFlags & (0x08 | 0x40)) { + if (distance < 0.0) { + attenuation = 1.0; + } else { + attenuation = 1.0 - (distance / 6.0); + } + } - if (texturePath !== undefined) { - textureDef.path = texturePath; - textureDefs.push(textureDef); + if (attenuation <= 0.001) { + attenuation = 0.0; + newAlpha = 0.0; + } else if (attenuation <= 1.0) { + newAlpha = attenuation * 255.0; } else { - textureDefs.push(null); + attenuation = 1.0; + newAlpha = 255.0; } - }); - - const material = new WMOMaterial(materialDef, textureDefs); - return material; - } + // Red + const tempR = color[0] * 255.0; + const newR = ((127.0 - tempR) * attenuation) + tempR; + color[0] = newR / 255.0; - clone() { - return new this.constructor(this.wmo, this.groupID, this.data, this.path); - } + // Green + const tempG = color[1] * 255.0; + const newG = ((127.0 - tempG) * attenuation) + tempG; + color[1] = newG / 255.0; - dispose() { - this.geometry.dispose(); + // Blue + const tempB = color[2] * 255.0; + const newB = ((127.0 - tempB) * attenuation) + tempB; + color[2] = newB / 255.0; - this.material.materials.forEach((material) => { - material.dispose(); - }); + // Alpha + color[3] = newAlpha / 255.0; + } } } diff --git a/src/lib/pipeline/wmo/group/loader/definition.js b/src/lib/pipeline/wmo/group/loader/definition.js new file mode 100644 index 00000000..e8796512 --- /dev/null +++ b/src/lib/pipeline/wmo/group/loader/definition.js @@ -0,0 +1,247 @@ +import MathUtil from '../../../../utils/math-util'; + +class WMOGroupDefinition { + + constructor(path, index, rootHeader, groupData) { + this.path = path; + this.index = index; + this.groupID = groupData.MOGP.groupID; + + this.header = { + batchCounts: groupData.MOGP.batchCounts, + batchOffsets: groupData.MOGP.batchOffsets, + portalCount: groupData.MOGP.portalCount, + portalOffset: groupData.MOGP.portalOffset, + flags: groupData.MOGP.flags + }; + + this.doodadRefs = groupData.MODR ? groupData.MODR.doodadIndices : []; + + this.createBoundingBox(groupData.MOGP); + + this.createAttributes(rootHeader, groupData); + this.createMaterialRefs(groupData); + this.batches = groupData.MOBA.batches; + + this.bspNodes = groupData.MOBN.nodes; + this.bspPlaneIndices = new Uint16Array(groupData.MOBR.indices); + } + + createBoundingBox(mogp) { + const boundingBox = this.boundingBox = {}; + + boundingBox.min = mogp.boundingBox.min; + boundingBox.max = mogp.boundingBox.max; + } + + createAttributes(rootHeader, groupData) { + const attributes = this.attributes = {}; + + const indexCount = groupData.MOVI.triangles.length; + const vertexCount = groupData.MOVT.vertices.length; + + const indices = attributes.indices = new Uint16Array(indexCount); + this.assignIndices(indexCount, groupData.MOVI, indices); + + const positions = attributes.positions = new Float32Array(vertexCount * 3); + this.assignVertexPositions(vertexCount, groupData.MOVT, positions); + + const uvs = attributes.uvs = new Float32Array(vertexCount * 2); + this.assignUVs(vertexCount, groupData.MOTV, uvs); + + const normals = attributes.normals = new Float32Array(vertexCount * 3); + this.assignVertexNormals(vertexCount, groupData.MONR, normals); + + // Manipulate vertex colors a la FixColorVertexAlpha + this.fixVertexColors(vertexCount, rootHeader, groupData.MOGP, groupData.MOBA, groupData.MOCV); + + const colors = attributes.colors = new Float32Array(vertexCount * 4); + this.assignVertexColors(vertexCount, rootHeader, groupData.MOGP, groupData.MOCV, colors); + } + + assignVertexPositions(vertexCount, movt, attribute) { + for (let index = 0; index < vertexCount; ++index) { + const vertex = movt.vertices[index]; + + attribute.set([vertex[0], vertex[1], vertex[2]], index * 3); + } + } + + assignUVs(vertexCount, motv, attribute) { + for (let index = 0; index < vertexCount; ++index) { + const uv = motv.textureCoords[index]; + + attribute.set(uv, index * 2); + } + } + + assignVertexNormals(vertexCount, monr, attribute) { + for (let index = 0; index < vertexCount; ++index) { + const normal = monr.normals[index]; + + attribute.set([normal[0], normal[1], normal[2]], index * 3); + } + } + + assignIndices(_indexCount, movi, attribute) { + attribute.set(movi.triangles, 0); + } + + assignVertexColors(vertexCount, rootHeader, mogp, mocv, attribute) { + if (!mocv) { + // Assign default vertex color. + for (let index = 0; index < vertexCount; ++index) { + const r = 127.0 / 255.0; + const g = 127.0 / 255.0; + const b = 127.0 / 255.0; + const a = 1.0; + + attribute.set([r, g, b, a], index * 4); + } + + return; + } + + const mod = { r: 0, g: 0, b: 0, a: 0 }; + + // For interior groups, add root ambient color to vertex colors. + if (mogp.interior) { + mod.r = rootHeader.ambientColor.r / 2.0; + mod.g = rootHeader.ambientColor.g / 2.0; + mod.b = rootHeader.ambientColor.b / 2.0; + } + + for (let index = 0; index < vertexCount; ++index) { + const color = mocv.colors[index]; + + const r = (color.r + mod.r) / 255.0; + const g = (color.g + mod.g) / 255.0; + const b = (color.b + mod.b) / 255.0; + const a = color.a / 255.0; + + attribute.set([r, g, b, a], index * 4); + } + } + + fixVertexColors(vertexCount, rootHeader, mogp, moba, mocv) { + if (!mocv) { + return; + } + + const { batchCounts, batchOffsets } = mogp; + + let batchStartB = 0; + + if (batchCounts.a > 0) { + const firstBatchB = moba.batches[batchOffsets.b]; + batchStartB = firstBatchB ? firstBatchB.firstVertex : vertexCount; + } + + // Root Flag 0x08: something about outdoor groups + if (rootHeader.flags & 0x08) { + for (let index = batchStartB; index < vertexCount; ++index) { + const color = mocv.colors[index]; + color.a = mogp.exterior ? 255 : 0; + } + + return; + } + + const mod = {}; + + // Root Flag 0x02: skip ambient color when fixing vertex colors + if (rootHeader.flags & 0x02) { + mod.r = 0; + mod.g = 0; + mod.b = 0; + } else { + mod.r = rootHeader.ambientColor.r; + mod.g = rootHeader.ambientColor.g; + mod.b = rootHeader.ambientColor.b; + } + + for (let index = 0; index < batchStartB; ++index) { + const color = mocv.colors[index]; + const alpha = color.a / 255.0; + + color.r -= mod.r; + color.g -= mod.g; + color.b -= mod.b; + + color.r -= (alpha * color.r); + color.g -= (alpha * color.g); + color.b -= (alpha * color.b); + + color.r = MathUtil.clamp(color.r, 0, 255); + color.g = MathUtil.clamp(color.g, 0, 255); + color.b = MathUtil.clamp(color.b, 0, 255); + + color.r /= 2.0; + color.g /= 2.0; + color.b /= 2.0; + } + + for (let index = batchStartB; index < vertexCount; ++index) { + const color = mocv.colors[index]; + + color.r = (color.r - mod.r) + ((color.r * color.a) >> 6); + color.g = (color.g - mod.g) + ((color.g * color.a) >> 6); + color.b = (color.b - mod.b) + ((color.b * color.a) >> 6); + + color.r /= 2.0; + color.g /= 2.0; + color.b /= 2.0; + + color.r = MathUtil.clamp(color.r, 0, 255); + color.g = MathUtil.clamp(color.g, 0, 255); + color.b = MathUtil.clamp(color.b, 0, 255); + + color.a = mogp.exterior ? 255 : 0; + } + } + + createMaterialRefs(groupData) { + const refs = this.materialRefs = []; + + const { batchOffsets } = groupData.MOGP; + const batchCount = groupData.MOBA.batches.length; + + for (let index = 0; index < batchCount; ++index) { + const batch = groupData.MOBA.batches[index]; + + const ref = {}; + + ref.materialIndex = batch.materialIndex; + ref.interior = groupData.MOGP.interior; + + if (index >= batchOffsets.c) { + ref.batchType = 3; + } else if (index >= batchOffsets.b) { + ref.batchType = 2; + } else { + ref.batchType = 1; + } + + refs.push(ref); + } + } + + // Returns an array of references to typed arrays that we'd like to transfer across worker + // boundaries. + get transferable() { + const list = []; + + list.push(this.attributes.indices.buffer); + list.push(this.attributes.positions.buffer); + list.push(this.attributes.uvs.buffer); + list.push(this.attributes.normals.buffer); + list.push(this.attributes.colors.buffer); + + list.push(this.bspPlaneIndices.buffer); + + return list; + } + +} + +export default WMOGroupDefinition; diff --git a/src/lib/pipeline/wmo/group/loader/index.js b/src/lib/pipeline/wmo/group/loader/index.js new file mode 100644 index 00000000..926a5f18 --- /dev/null +++ b/src/lib/pipeline/wmo/group/loader/index.js @@ -0,0 +1,82 @@ +import WorkerPool from '../../../worker/pool'; +import WMOGroup from '../'; + +class WMOGroupLoader { + + static cache = new Map(); + + static refCounts = new Map(); + static pendingUnload = new Set(); + static unloaderRunning = false; + + static UNLOAD_INTERVAL = 15000; + + static load(root, index, rawPath) { + const path = rawPath.toUpperCase(); + + // Prevent unintended unloading. + if (this.pendingUnload.has(path)) { + this.pendingUnload.delete(path); + } + + // Background unloader might need to be started. + if (!this.unloaderRunning) { + this.unloaderRunning = true; + this.backgroundUnload(); + } + + // Keep track of references. + const refCount = (this.refCounts.get(path) || 0) + 1; + this.refCounts.set(path, refCount); + + if (!this.cache.has(path)) { + const worker = WorkerPool.enqueue('WMOGroup', path, index, root.header); + + const promise = worker.then((def) => { + return new WMOGroup(root, def); + }); + + this.cache.set(path, promise); + } + + return this.cache.get(path); + } + + static loadByIndex(root, index) { + const suffix = `000${index}`.slice(-3); + const path = root.path.replace(/\.wmo/i, `_${suffix}.wmo`); + + return this.load(root, index, path); + } + + static unload(group) { + const path = group.path.toUpperCase(); + + const refCount = (this.refCounts.get(path) || 1) - 1; + + if (refCount <= 0) { + this.pendingUnload.add(path); + } else { + this.refCounts.set(path, refCount); + } + } + + static backgroundUnload() { + for (const path of this.pendingUnload) { + if (this.cache.has(path)) { + this.cache.get(path).then((group) => { + group.dispose(); + }); + } + + this.cache.delete(path); + this.refCounts.delete(path); + this.pendingUnload.delete(path); + } + + setTimeout(this.backgroundUnload.bind(this), this.UNLOAD_INTERVAL); + } + +} + +export default WMOGroupLoader; diff --git a/src/lib/pipeline/wmo/group/loader.js b/src/lib/pipeline/wmo/group/loader/worker.js similarity index 50% rename from src/lib/pipeline/wmo/group/loader.js rename to src/lib/pipeline/wmo/group/loader/worker.js index 5951d236..7b2b65fc 100644 --- a/src/lib/pipeline/wmo/group/loader.js +++ b/src/lib/pipeline/wmo/group/loader/worker.js @@ -1,15 +1,20 @@ import { DecodeStream } from 'blizzardry/lib/restructure'; import WMOGroup from 'blizzardry/lib/wmo/group'; -import Loader from '../../../net/loader'; +import Loader from '../../../../net/loader'; +import WMOGroupDefinition from './definition'; const loader = new Loader(); -export default function(path) { +export default function(path, index, rootHeader) { return loader.load(path).then((raw) => { const buffer = new Buffer(new Uint8Array(raw)); const stream = new DecodeStream(buffer); - const data = WMOGroup.decode(stream); - return data; + + const groupData = WMOGroup.decode(stream); + + const def = new WMOGroupDefinition(path, index, rootHeader, groupData); + + return def; }); } diff --git a/src/lib/pipeline/wmo/group/view.js b/src/lib/pipeline/wmo/group/view.js new file mode 100644 index 00000000..77c5902d --- /dev/null +++ b/src/lib/pipeline/wmo/group/view.js @@ -0,0 +1,21 @@ +import * as THREE from 'three'; + +class WMOGroupView extends THREE.Mesh { + + constructor(group, geometry, material) { + super(); + + this.matrixAutoUpdate = false; + + this.group = group; + this.geometry = geometry; + this.material = material; + } + + clone() { + return this.group.createView(); + } + +} + +export default WMOGroupView; diff --git a/src/lib/pipeline/wmo/index.js b/src/lib/pipeline/wmo/index.js index ff60c844..c793e592 100644 --- a/src/lib/pipeline/wmo/index.js +++ b/src/lib/pipeline/wmo/index.js @@ -1,47 +1,407 @@ -import THREE from 'three'; +import ContentQueue from '../../utils/content-queue'; +import WMORootLoader from './root/loader'; +import WMOGroupLoader from './group/loader'; +import M2Blueprint from '../m2/blueprint'; -class WMO extends THREE.Group { +class WMO { - static cache = {}; + static LOAD_GROUP_INTERVAL = 1; + static LOAD_GROUP_WORK_FACTOR = 1 / 10; + static LOAD_GROUP_WORK_MIN = 2; - constructor(path, data) { - super(); + static LOAD_DOODAD_INTERVAL = 1; + static LOAD_DOODAD_WORK_FACTOR = 1 / 20; + static LOAD_DOODAD_WORK_MIN = 2; - this.matrixAutoUpdate = false; + constructor(filename, doodadSetIndex = null, entryID = null, parentCounters = null) { + this.filename = filename; + this.doodadSetIndex = doodadSetIndex; + this.entryID = entryID; - this.path = path; - this.data = data; + this.counters = this.stubCounters(); + this.parentCounters = parentCounters || this.stubCounters(); - this.groupCount = data.MOHD.groupCount; + this.root = null; + this.groups = new Map(); + + this.doodads = new Map(); + this.animatedDoodads = new Map(); + + this.doodadSet = []; + + this.doodadRefs = { + doodad: new Map(), + group: new Map() + }; + + this.views = { + root: null, + groups: new Map(), + portals: new Map() + }; + + this.queues = { + loadGroup: new ContentQueue( + ::this.processLoadGroup, + this.constructor.LOAD_GROUP_INTERVAL, + this.constructor.LOAD_GROUP_WORK_FACTOR, + this.constructor.LOAD_GROUP_WORK_MIN + ), + + loadDoodad: new ContentQueue( + ::this.processLoadDoodad, + this.constructor.LOAD_DOODAD_INTERVAL, + this.constructor.LOAD_DOODAD_WORK_FACTOR, + this.constructor.LOAD_DOODAD_WORK_MIN + ) + }; + + this.pendingUnload = null; + this.unloading = false; + } + + stubCounters() { + return { + loadingGroups: 0, + loadingDoodads: 0, + loadedGroups: 0, + loadedDoodads: 0, + animatedDoodads: 0 + }; + } + + load() { + return WMORootLoader.load(this.filename).then((root) => { + this.root = root; + + const rootView = this.root.createView(); + this.views.root = rootView; + + this.loadPortals(this.root.portals); + + if (this.doodadSetIndex !== null) { + this.doodadSet = this.root.doodadSet(this.doodadSetIndex); + } + + this.enqueueLoadGroups(); + + return this; + }); + } + + enqueueLoadGroups() { + const { exteriorGroupIndices, interiorGroupIndices } = this.root; + + for (let egi = 0, eglen = exteriorGroupIndices.length; egi < eglen; ++egi) { + const groupIndex = exteriorGroupIndices[egi]; + this.enqueueLoadGroup(groupIndex); + } + + for (let igi = 0, iglen = interiorGroupIndices.length; igi < iglen; ++igi) { + const groupIndex = interiorGroupIndices[igi]; + this.enqueueLoadGroup(groupIndex); + } + } + + enqueueLoadGroup(groupIndex) { + // Already loaded. + if (this.groups.has(groupIndex)) { + return; + } + + this.queues.loadGroup.add(groupIndex, groupIndex); + + this.parentCounters.loadingGroups++; + this.counters.loadingGroups++; + } + + processLoadGroup(groupIndex) { + // Already loaded. + if (this.groups.has(groupIndex)) { + this.parentCounters.loadingGroups--; + this.counters.loadingGroups--; + return; + } + + WMOGroupLoader.loadByIndex(this.root, groupIndex).then((group) => { + if (this.unloading) { + return; + } + + this.loadGroup(group); + + this.parentCounters.loadingGroups--; + this.counters.loadingGroups--; + this.parentCounters.loadedGroups++; + this.counters.loadedGroups++; + }); + } + + loadGroup(group) { + const groupView = group.createView(); + this.placeGroupView(groupView); + this.views.groups.set(group.index, groupView); + + this.groups.set(group.index, group); + + if (group.doodadRefs) { + this.enqueueLoadGroupDoodads(group); + } + } + + loadPortals(portals) { + for (let index = 0; index < portals.length; ++index) { + const portal = portals[index]; + + const portalView = portal.createView(); + this.views.portals.set(index, portalView); + this.placePortalView(portalView); + } + } + + enqueueLoadGroupDoodads(group) { + group.doodadRefs.forEach((doodadIndex) => { + const doodadEntry = this.doodadSet.entries[doodadIndex - this.doodadSet.start]; + + // Since the doodad set is filtered based on the requested set in the entry, not all + // doodads referenced by a group will be present. + if (!doodadEntry) { + return; + } + + // Assign the index as an id property on the entry. + doodadEntry.id = doodadIndex; + + const refCount = this.addDoodadRef(doodadEntry, group); + + // Only enqueue load on the first reference, since it'll already have been enqueued on + // subsequent references. + if (refCount === 1) { + this.enqueueLoadDoodad(doodadEntry); + } + }); + } + + enqueueLoadDoodad(doodadEntry) { + // Already loading or loaded. + if (this.queues.loadDoodad.has(doodadEntry.id) || this.doodads.has(doodadEntry.id)) { + return; + } + + this.queues.loadDoodad.add(doodadEntry.id, doodadEntry); + + this.parentCounters.loadingDoodads++; + this.counters.loadingDoodads++; + } + + processLoadDoodad(doodadEntry) { + // Already loaded. + if (this.doodads.has(doodadEntry.id)) { + this.parentCounters.loadingDoodads--; + this.counters.loadingDoodads--; + return; + } + + M2Blueprint.load(doodadEntry.filename).then((doodad) => { + if (this.unloading) { + return; + } + + this.loadDoodad(doodadEntry, doodad); + + this.parentCounters.loadingDoodads--; + this.counters.loadingDoodads--; + this.parentCounters.loadedDoodads++; + this.counters.loadedDoodads++; + + if (doodad.animated) { + this.parentCounters.animatedDoodads++; + this.counters.animatedDoodads++; + } + }); + } + + loadDoodad(doodadEntry, doodad) { + doodad.entryID = doodadEntry.id; + + this.placeDoodad(doodadEntry, doodad); + + if (doodad.animated) { + this.animatedDoodads.set(doodadEntry.id, doodad); + + if (doodad.animations.length > 0) { + // TODO: Do WMO doodads have more than one animation? If so, which one should play? + doodad.animations.playAnimation(0); + doodad.animations.playAllSequences(); + } + } + + this.doodads.set(doodadEntry.id, doodad); + } + + unload() { + this.unloading = true; + + this.queues.loadGroup.clear(); + this.queues.loadDoodad.clear(); + + this.counters.loadingGroups = 0; + this.counters.loadedGroups = 0; + this.counters.loadingDoodads = 0; + this.counters.loadedDoodads = 0; + this.counters.animatedDoodads = 0; + + for (const group of this.groups.values()) { + WMOGroupLoader.unload(group); + } + + for (const doodad of this.doodads.values()) { + M2Blueprint.unload(doodad); + } + + WMORootLoader.unload(this.root); this.groups = new Map(); - this.indoorGroupIDs = []; - this.outdoorGroupIDs = []; + this.doodads = new Map(); + this.animatedDoodads = new Map(); + this.doodadRefs = new Map(); + + this.views.root = null; + this.views.groups = new Map(); + this.views.portals = new Map(); + + this.root = null; + this.doodadSetIndex = null; + this.entryID = null; + this.filename = null; + } + + placeGroupView(groupView) { + // Add to scene and update matrices + this.views.root.add(groupView); + groupView.updateMatrix(); + groupView.updateMatrixWorld(); + } + + placePortalView(portalView) { + // Add to scene and update matrices + this.views.root.add(portalView); + portalView.updateMatrix(); + portalView.updateMatrixWorld(); + } - // Separate group IDs by indoor/outdoor flag. This allows us to queue outdoor groups to - // load before indoor groups. - for (let i = 0; i < this.groupCount; ++i) { - const group = data.MOGI.groups[i]; + placeDoodad(doodadEntry, doodad) { + const { position, rotation, scale } = doodadEntry; - if (group.indoor) { - this.indoorGroupIDs.push(i); - } else { - this.outdoorGroupIDs.push(i); + doodad.position.set(-position.x, -position.y, position.z); + + // Adjust doodad rotation to match Wowser's axes. + const quat = doodad.quaternion; + quat.set(rotation.x, rotation.y, -rotation.z, -rotation.w); + + doodad.scale.set(scale, scale, scale); + + // Add to scene and update matrices + this.views.root.add(doodad); + doodad.updateMatrix(); + doodad.updateMatrixWorld(); + } + + addDoodadRef(doodadEntry, group) { + if (!this.doodadRefs.doodad.has(doodadEntry.id)) { + this.doodadRefs.doodad.set(doodadEntry.id, new Set()); + } + + if (!this.doodadRefs.group.has(group.index)) { + this.doodadRefs.group.set(group.index, new Set()); + } + + const byDoodad = this.doodadRefs.doodad.get(doodadEntry.id); + const byGroup = this.doodadRefs.group.get(group.index); + + byDoodad.add(group.index); + byGroup.add(doodadEntry.id); + + const refCount = byDoodad.size; + + return refCount; + } + + removeDoodadRef(doodadEntry, group) { + const byDoodad = this.doodadRefs.doodad.get(doodadEntry.id); + const byGroup = this.doodadRefs.group.get(group.index); + + if (!byDoodad) { + return 0; + } + + byDoodad.delete(group.index); + byGroup.delete(doodadEntry.id); + + const refCount = doodadRefs.doodad.size; + + if (refCount === 0) { + this.doodadRefs.doodad.delete(doodadEntry.id); + this.doodadRefs.group.delete(group.index); + } + + return refCount; + } + + groupsForDoodad(doodad) { + const groupIDs = this.doodadRefs.doodad.get(doodad.entryID) || []; + const groups = []; + + for (const groupID of groupIDs) { + const group = this.groups.get(groupID); + + if (group) { + groups.push(group); } } + + return groups; } - doodadSet(doodadSet) { - const set = this.data.MODS.sets[doodadSet]; - const { startIndex: start, doodadCount: count } = set; + doodadsForGroup(group) { + const doodadIDs = this.doodadRefs.group.get(group.index) || []; + const doodads = []; - const entries = this.data.MODD.doodads.slice(start, start + count); + for (const doodadID of doodadIDs) { + const doodad = this.doodads.get(doodadID); + + if (doodad) { + doodads.push(doodad); + } + } - return entries; + return doodads; } - clone() { - return new this.constructor(this.path, this.data); + animate(delta, camera, cameraMoved) { + if (!this.views.root) { + return; + } + + const doodads = this.animatedDoodads.values(); + + for (const doodad of doodads) { + if (!doodad.visible) { + continue; + } + + if (doodad.receivesAnimationUpdates && doodad.animations.length > 0) { + doodad.animations.update(delta); + } + + if (cameraMoved && doodad.billboards.length > 0) { + doodad.applyBillboards(camera); + } + + if (doodad.skeletonHelper) { + doodad.skeletonHelper.update(); + } + } } } diff --git a/src/lib/pipeline/wmo/material/index.js b/src/lib/pipeline/wmo/material/index.js index 72093d1e..d3d5e77d 100644 --- a/src/lib/pipeline/wmo/material/index.js +++ b/src/lib/pipeline/wmo/material/index.js @@ -1,67 +1,53 @@ -import THREE from 'three'; +import * as THREE from 'three'; import TextureLoader from '../../texture-loader'; -import vertexShader from './shader.vert'; -import fragmentShader from './shader.frag'; +import vertexShader from './shaders/vertex.glsl'; +import fragmentShader from './shaders/fragment.glsl'; class WMOMaterial extends THREE.ShaderMaterial { - constructor(def, textureDefs) { + constructor(def) { super(); - this.textures = []; + this.key = def.key; + + this.loadTextures(def.textures); this.uniforms = { - textures: { type: 'tv', value: [] }, - textureCount: { type: 'i', value: 0 }, - blendingMode: { type: 'i', value: def.blendMode }, - - useBaseColor: { type: 'i', value: 0 }, - baseColor: { type: 'c', value: new THREE.Color(0, 0, 0) }, - baseAlpha: { type: 'f', value: 0.0 }, - - indoor: { type: 'i', value: 0 }, - - // Managed by light manager - lightModifier: { type: 'f', value: 1.0 }, - ambientLight: { type: 'c', value: new THREE.Color(0.5, 0.5, 0.5) }, - diffuseLight: { type: 'c', value: new THREE.Color(0.25, 0.5, 1.0) }, - - // Managed by light manager - fogModifier: { type: 'f', value: 1.0 }, - fogColor: { type: 'c', value: new THREE.Color(0.25, 0.5, 1.0) }, - fogStart: { type: 'f', value: 5.0 }, - fogEnd: { type: 'f', value: 400.0 } - }; + textures: { type: 'tv', value: this.textures }, - if (def.useBaseColor) { - const baseColor = new THREE.Color( - def.baseColor.r / 255.0, - def.baseColor.g / 255.0, - def.baseColor.b / 255.0 - ); + // Light Params: [dir.x, dir.y, dir.z, modifier] + lightParams: { type: '4fv', value: new Float32Array([-1.0, -1.0, -1.0, 1.0]) }, + ambientColor: { type: '3fv', value: new Float32Array([0.5, 0.5, 0.5]) }, + diffuseColor: { type: '3fv', value: new Float32Array([0.25, 0.5, 1.0]) }, - const baseAlpha = def.baseColor.a / 255.0; + // Fog Params: [start, end, modifier] + fogParams: { type: '3fv', value: new Float32Array([5.0, 400.0, 1.0]) }, + fogColor: { type: '3fv', value: new Float32Array([0.25, 0.5, 1.0]) } + }; - this.uniforms.useBaseColor = { type: 'i', value: 1 }; - this.uniforms.baseColor = { type: 'c', value: baseColor }; - this.uniforms.baseAlpha = { type: 'f', value: baseAlpha }; - } + // Enable lighting + this.defines.USE_LIGHTING = 1; - // Tag lighting mode (based on group flags) - if (def.indoor) { - this.uniforms.indoor = { type: 'i', value: 1 }; + // Define interior + if (def.interior) { + this.defines.INTERIOR = 1; } - // Flag 0x01 (unlit) - // TODO: This is really only unlit at night. Needs to integrate with the light manager in - // some fashion. + // Define blending mode + this.defines.BLENDING_MODE = def.blendingMode; + + // Define batch type + this.defines.BATCH_TYPE = def.batchType; + + // Flag 0x10: unlit + // TODO: This is potentially only unlit at night. if (def.flags & 0x10) { - this.uniforms.lightModifier = { type: 'f', value: 0.0 }; + this.uniforms.lightParams.value[3] = 0.0; } // Transparent blending - if (def.blendMode === 1) { + if (def.blendingMode === 1) { this.transparent = true; this.side = THREE.DoubleSide; } @@ -80,34 +66,50 @@ class WMOMaterial extends THREE.ShaderMaterial { this.vertexShader = vertexShader; this.fragmentShader = fragmentShader; - - this.loadTextures(textureDefs); } // TODO: Handle texture flags and color. - loadTextures(textureDefs) { - const textures = []; + loadTextures(defs) { + const textures = this.textures = this.textures || []; + + // Ensure any existing textures are unloaded in the event we're changing to new textures. + this.unloadTextures(); - textureDefs.forEach((textureDef) => { - if (textureDef !== null) { - const texture = TextureLoader.load(textureDef.path, this.wrapping, this.wrapping, false); + for (let index = 0, textureCount = defs.length; index < textureCount; ++index) { + const def = defs[index]; + + if (def) { + const texture = TextureLoader.load(def.path, this.wrapping, this.wrapping, false); textures.push(texture); } - }); + } - this.textures = textures; + // Update texture count + this.defines.TEXTURE_COUNT = textures.length; - // Update shader uniforms to reflect loaded textures. - this.uniforms.textures = { type: 'tv', value: textures }; - this.uniforms.textureCount = { type: 'i', value: textures.length }; + // Ensure changes propagate to renderer + this.needsUpdate = true; + } + + unloadTextures() { + // Unload textures in the loader + for (const texture of this.textures) { + TextureLoader.unload(texture); + } + + // Clear array + this.textures.splice(0); + + // Update texture count + this.defines.TEXTURE_COUNT = 0; + + // Ensure changes propagate to renderer + this.needsUpdate = true; } dispose() { super.dispose(); - - this.textures.forEach((texture) => { - TextureLoader.unload(texture); - }); + this.unloadTextures(); } } diff --git a/src/lib/pipeline/wmo/material/loader/definition.js b/src/lib/pipeline/wmo/material/loader/definition.js new file mode 100644 index 00000000..b617310b --- /dev/null +++ b/src/lib/pipeline/wmo/material/loader/definition.js @@ -0,0 +1,47 @@ +class WMOMaterialDefinition { + + constructor(index, flags, blendingMode, shaderID, textures) { + this.index = index; + this.flags = flags; + this.blendingMode = blendingMode; + this.shaderID = shaderID; + this.textures = textures; + + // Comes from reference + this.batchType = null; + this.interior = null; + } + + forRef(ref) { + const clone = this.clone(); + + clone.batchType = ref.batchType; + clone.interior = ref.interior; + + return clone; + } + + get key() { + const key = []; + + key.push(this.index); + + if (this.batchType !== null) { + key.push(this.batchType); + } + + if (this.interior !== null) { + key.push(this.interior ? 'i' : 'e'); + } + + return key.join(';'); + } + + clone() { + const { index, flags, blendingMode, shaderID, textures } = this; + return new WMOMaterialDefinition(index, flags, blendingMode, shaderID, textures); + } + +} + +export default WMOMaterialDefinition; diff --git a/src/lib/pipeline/wmo/material/shader.frag b/src/lib/pipeline/wmo/material/shader.frag deleted file mode 100644 index 2b45b88f..00000000 --- a/src/lib/pipeline/wmo/material/shader.frag +++ /dev/null @@ -1,108 +0,0 @@ -varying vec2 vUv; - -varying vec4 vertexColor; -varying vec3 vertexWorldNormal; -varying float cameraDistance; - -uniform int textureCount; -uniform sampler2D textures[4]; -uniform int blendingMode; - -uniform float lightModifier; -uniform vec3 ambientLight; -uniform vec3 diffuseLight; - -uniform float fogModifier; -uniform float fogStart; -uniform float fogEnd; -uniform vec3 fogColor; - -uniform int indoor; - -// Given a light direction and normal, return a directed diffuse light. -vec3 createGlobalLight(vec3 lightDirection, vec3 lightNormal, vec3 diffuseLight, vec3 ambientLight) { - float light = dot(lightNormal, -lightDirection); - - if (light < 0.0) { - light = 0.0; - } else if (light > 0.5) { - light = 0.5 + ((light - 0.5) * 0.65); - } - - vec3 directedDiffuseLight = diffuseLight.rgb * light; - - directedDiffuseLight.rgb += ambientLight.rgb; - directedDiffuseLight = saturate(directedDiffuseLight); - - return directedDiffuseLight; -} - -vec4 applyFog(vec4 color) { - float fogFactor = (fogEnd - cameraDistance) / (fogEnd - fogStart); - fogFactor = 1.0 - clamp(fogFactor, 0.0, 1.0); - float fogColorFactor = fogFactor * fogModifier; - - color.rgb = mix(color.rgb, fogColor.rgb, fogColorFactor); - - // Ensure certain blending mode pixels become fully opaque by fog end. - if (cameraDistance >= fogEnd) { - color.rgb = fogColor.rgb; - color.a = 1.0; - } - - return color; -} - -vec4 lightIndoor(vec4 color, vec4 vertexColor, vec3 light) { - vec3 groupColor = vertexColor.rgb; - - vec3 indoorLight; - - indoorLight = (vertexColor.a * light.rgb) + ((1.0 - vertexColor.a) * groupColor); - indoorLight.rgb = saturate(indoorLight.rgb); - - color.rgb *= indoorLight; - - return color; -} - -vec4 lightOutdoor(vec4 color, vec4 vertexColor, vec3 light) { - vec3 outdoorLight = light.rgb += (vertexColor.rgb * 2.0); - outdoorLight.rgb = saturate(outdoorLight.rgb); - - color.rgb *= outdoorLight; - - return color; -} - -void main() { - vec3 lightDirection = normalize(vec3(-1, -1, -1)); - vec3 lightNormal = normalize(vertexWorldNormal); - vec3 globalLight = createGlobalLight(lightDirection, lightNormal, diffuseLight, ambientLight); - - // Base layer - vec4 color = texture2D(textures[0], vUv); - - // Knock out transparent pixels in transparent blending mode (1). - if (blendingMode == 1 && color.a < (10.0 / 255.0)) { - discard; - } - - // Force transparent pixels to fully opaque if in opaque blending mode (0). Needed to prevent - // transparent pixels from becoming inappropriately bright. - if (blendingMode == 0) { - color.a = 1.0; - } - - if (lightModifier > 0.0) { - if (indoor == 1) { - color = lightIndoor(color, vertexColor, globalLight); - } else { - color = lightOutdoor(color, vertexColor, globalLight); - } - } - - color = applyFog(color); - - gl_FragColor = color; -} diff --git a/src/lib/pipeline/wmo/material/shader.vert b/src/lib/pipeline/wmo/material/shader.vert deleted file mode 100644 index 55eeb083..00000000 --- a/src/lib/pipeline/wmo/material/shader.vert +++ /dev/null @@ -1,52 +0,0 @@ -precision highp float; - -varying vec2 vUv; - -varying vec3 vertexWorldNormal; -varying float cameraDistance; - -attribute vec3 color; -attribute float alpha; - -varying vec4 vertexColor; - -uniform int indoor; - -uniform int useBaseColor; -uniform vec3 baseColor; -uniform float baseAlpha; - -vec4 saturate(vec4 value) { - vec4 result = clamp(value, 0.0, 1.0); - return result; -} - -vec3 saturate(vec3 value) { - vec3 result = clamp(value, 0.0, 1.0); - return result; -} - -float saturate(float value) { - float result = clamp(value, 0.0, 1.0); - return result; -} - -void main() { - vUv = uv; - - vertexColor = vec4(color, alpha); - - if (indoor == 1 && useBaseColor == 1) { - vertexColor.rgb = saturate(vertexColor.rgb + baseColor.rgb); - vertexColor.a = saturate(mod(vertexColor.a, 1.0) + (1.0 - baseAlpha)); - } - - vec3 vertexWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; - cameraDistance = distance(cameraPosition, vertexWorldPosition); - - vertexWorldNormal = (modelMatrix * vec4(normal, 0.0)).xyz; - - gl_Position = projectionMatrix * - modelViewMatrix * - vec4(position, 1.0); -} diff --git a/src/lib/pipeline/wmo/material/shaders/fragment/combiners.glsl b/src/lib/pipeline/wmo/material/shaders/fragment/combiners.glsl new file mode 100644 index 00000000..07a61035 --- /dev/null +++ b/src/lib/pipeline/wmo/material/shaders/fragment/combiners.glsl @@ -0,0 +1,21 @@ +vec4 combinersOpaque() { + vec4 sampled0 = texture2D(textures[0], coords[0]); + + vec4 result; + + result.rgb = colors[0].rgb * sampled0.rgb * 2.0; + result.a = colors[0].a; + + return result; +} + +vec4 combinersDiffuse() { + vec4 sampled0 = texture2D(textures[0], coords[0]); + + vec4 result; + + result.rgb = colors[0].rgb * sampled0.rgb * 2.0; + result.a = colors[0].a * sampled0.a; + + return result; +} diff --git a/src/lib/pipeline/wmo/material/shaders/fragment/functions.glsl b/src/lib/pipeline/wmo/material/shaders/fragment/functions.glsl new file mode 100644 index 00000000..42e6d3f8 --- /dev/null +++ b/src/lib/pipeline/wmo/material/shaders/fragment/functions.glsl @@ -0,0 +1,9 @@ +vec4 finalizeResult(in vec4 result) { + // Fog + result.rgb = mix(result.rgb, fog.rgb, fog.a * materialParams.z); + + // Opacity + result.a *= materialParams.w; + + return result; +} diff --git a/src/lib/pipeline/wmo/material/shaders/fragment/header.glsl b/src/lib/pipeline/wmo/material/shaders/fragment/header.glsl new file mode 100644 index 00000000..c3315473 --- /dev/null +++ b/src/lib/pipeline/wmo/material/shaders/fragment/header.glsl @@ -0,0 +1,6 @@ +uniform sampler2D textures[2]; +uniform vec4 materialParams; + +varying vec2 coords[2]; +varying vec4 colors[2]; +varying vec4 fog; diff --git a/src/lib/pipeline/wmo/material/shaders/fragment/main.glsl b/src/lib/pipeline/wmo/material/shaders/fragment/main.glsl new file mode 100644 index 00000000..2504e01a --- /dev/null +++ b/src/lib/pipeline/wmo/material/shaders/fragment/main.glsl @@ -0,0 +1,24 @@ +#pragma glslify: import('./header.glsl') +#pragma glslify: import('./functions.glsl') +#pragma glslify: import('./combiners.glsl') + +void main() { + vec4 result; + + // Branch for combiners + #if defined(COMBINERS_OPAQUE) + result = combinersOpaque(); + #elif defined(COMBINERS_DIFFUSE) + result = combinersDiffuse(); + #endif + + // Alpha test + if (result.a < materialParams.x) { + discard; + } + + // Finalize + result = finalizeResult(result); + + gl_FragColor = result; +} diff --git a/src/lib/pipeline/wmo/material/shaders/index.js b/src/lib/pipeline/wmo/material/shaders/index.js new file mode 100644 index 00000000..bd7d8ac4 --- /dev/null +++ b/src/lib/pipeline/wmo/material/shaders/index.js @@ -0,0 +1,6 @@ +const shaders = { + Vertex: require('./vertex/main.glsl'), + Fragment: require('./fragment/main.glsl') +}; + +export default shaders; diff --git a/src/lib/pipeline/wmo/material/shaders/vertex/functions.glsl b/src/lib/pipeline/wmo/material/shaders/vertex/functions.glsl new file mode 100644 index 00000000..00a05439 --- /dev/null +++ b/src/lib/pipeline/wmo/material/shaders/vertex/functions.glsl @@ -0,0 +1,35 @@ +float saturate(float value) { + return clamp(value, 0.0, 1.0); +} + +vec3 saturate(vec3 value) { + return clamp(value, 0.0, 1.0); +} + +vec4 saturate(vec4 value) { + return clamp(value, 0.0, 1.0); +} + +vec3 createLight(in vec3 normal, in vec3 direction, in vec3 diffuseColor, in vec3 ambientColor) { + float factor = saturate(dot(-direction.xyz, normalize(normal.xyz))); + + vec3 light = saturate((diffuseColor.rgb * factor) + ambientColor.rgb); + + return light; +} + +vec4 createFog(in float cameraDistance) { + float f1 = (cameraDistance * fogParams.x) + fogParams.y; + float f2 = max(f1, 0.0); + float f3 = pow(f2, fogParams.z); + float f4 = min(f3, 1.0); + + float fogFactor = 1.0 - f4; + + vec4 fog; + + fog.rgb = fogColor.rgb; + fog.a = fogFactor; + + return fog; +} diff --git a/src/lib/pipeline/wmo/material/shaders/vertex/header.glsl b/src/lib/pipeline/wmo/material/shaders/vertex/header.glsl new file mode 100644 index 00000000..8b3be650 --- /dev/null +++ b/src/lib/pipeline/wmo/material/shaders/vertex/header.glsl @@ -0,0 +1,16 @@ +// THREE's built-in color attribute is a vec3, but Wowser needs RGBA. +attribute vec4 acolor; + +uniform vec4 fogParams; +uniform vec4 fogColor; + +uniform vec4 sunParams; +uniform vec4 sunDiffuseColor; +uniform vec4 sunAmbientColor; + +uniform vec4 materialParams; +uniform vec4 emissiveColor; + +varying vec2 coords[2]; +varying vec4 colors[2]; +varying vec4 fog; diff --git a/src/lib/pipeline/wmo/material/shaders/vertex/main.glsl b/src/lib/pipeline/wmo/material/shaders/vertex/main.glsl new file mode 100644 index 00000000..62ed7476 --- /dev/null +++ b/src/lib/pipeline/wmo/material/shaders/vertex/main.glsl @@ -0,0 +1,50 @@ +#pragma glslify: import('./header.glsl') +#pragma glslify: import('./functions.glsl') + +void main() { + vec3 objectPosition = (modelMatrix * vec4(position, 1.0)).xyz; + vec3 objectNormal = (modelMatrix * vec4(normal, 0.0)).xyz; + + float cameraDistance = length(modelViewMatrix * vec4(position, 1.0)); + + // t1 coordinate + coords[0] = uv; + + // Fog + fog = createFog(cameraDistance); + + #if USE_LIGHTING == 1 + vec3 light = createLight(objectNormal.xyz, sunParams.xyz, sunDiffuseColor.rgb, sunAmbientColor.rgb); + light = mix(light, vec3(1.0, 1.0, 1.0), 1.0 - materialParams.y); + #else + vec3 light = vec3(1.0, 1.0, 1.0); + #endif + + #if USE_VERTEX_COLOR == 1 + vec4 vertexColor = vec4(acolor.rgb, acolor.a); + #else + vec4 vertexColor = vec4(0.5, 0.5, 0.5, 1.0); + #endif + + #if BATCH_TYPE == 1 + // Transition between vertex color and light based on vertex alpha + colors[0].rgb = saturate(mix(vertexColor.rgb, light.rgb * 0.5, vertexColor.a) + emissiveColor.rgb); + colors[0].a = vertexColor.a; + #endif + + #if BATCH_TYPE == 2 + // Transition between vertex color and light added to vertex color + colors[0].rgb = saturate(mix(vertexColor.rgb, (light.rgb * 0.5) + vertexColor.rgb, vertexColor.a)); + colors[0].a = vertexColor.a; + #endif + + #if BATCH_TYPE == 3 + // Multiply vertex color and light + colors[0].rgb = saturate((vertexColor.rgb * light.rgb) + emissiveColor.rgb); + colors[0].a = vertexColor.a; + #endif + + colors[1] = vec4(0.0); + + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} diff --git a/src/lib/pipeline/wmo/portal/index.js b/src/lib/pipeline/wmo/portal/index.js new file mode 100644 index 00000000..a26d74b5 --- /dev/null +++ b/src/lib/pipeline/wmo/portal/index.js @@ -0,0 +1,70 @@ +import * as THREE from 'three'; + +import WMOPortalView from './view'; + +class WMOPortal { + + constructor(def) { + this.index = def.index; + + const vertexCount = def.vertices.length / 3; + + const vertices = this.vertices = []; + + this.boundingBox = new THREE.Box3(); + + for (let vindex = 0; vindex < vertexCount; ++vindex) { + const vertex = new THREE.Vector3( + def.vertices[vindex * 3], + def.vertices[vindex * 3 + 1], + def.vertices[vindex * 3 + 2] + ); + + // Stretch bounding box + this.boundingBox.expandByPoint(vertex); + + vertices.push(vertex); + } + + const normal = this.normal = new THREE.Vector3(def.normal[0], def.normal[1], def.normal[2]); + const constant = this.constant = def.constant; + this.plane = new THREE.Plane(normal, constant); + + this.createGeometry(vertices); + this.createMaterial(); + } + + createView() { + return new WMOPortalView(this, this.geometry, this.material); + } + + createGeometry(vertices) { + const geometry = this.geometry = new THREE.Geometry(); + + const vertexCount = vertices.length; + + for (let vindex = 0; vindex < vertexCount; ++vindex) { + geometry.vertices.push(vertices[vindex]); + } + + const faceCount = vertexCount - 3 + 1; + + for (let findex = 0; findex < faceCount; ++findex) { + geometry.faces.push(new THREE.Face3(findex + 1, findex + 2, 0)); + } + } + + createMaterial() { + const material = this.material = new THREE.MeshBasicMaterial; + + material.color = new THREE.Color(0xffff00); + material.side = THREE.DoubleSide; + material.opacity = 0.1; + material.transparent = true; + material.depthWrite = false; + material.visible = false; + } + +} + +export default WMOPortal; diff --git a/src/lib/pipeline/wmo/portal/view.js b/src/lib/pipeline/wmo/portal/view.js new file mode 100644 index 00000000..64f36912 --- /dev/null +++ b/src/lib/pipeline/wmo/portal/view.js @@ -0,0 +1,125 @@ +import * as THREE from 'three'; + +import THREEUtil from '../../../utils/three-util'; + +class WMOPortalView extends THREE.Mesh { + + constructor(portal, geometry, material) { + super(); + + this.matrixAutoUpdate = false; + + this.portal = portal; + this.geometry = geometry; + this.material = material; + } + + clone() { + return this.portal.createView(); + } + + /** + * Projects a new frustum from this portal using an origin point and restricting the new + * frustum to include only the spill from the given frustum. + * + * @param origin - Position to use when projecting new frustum + * @param frustum - Previous frustum (used to clip portal vertices) + * @param flip - Optional, specify that the new frustum sides should be flipped + * + * @returns - Frustum clipped by this portal + * + */ + createFrustum(camera, frustum, flip = false) { + const planes = []; + const vertices = []; + + const origin = camera.position; + + // Obtain vertices in world space + for (let vindex = 0, vcount = this.geometry.vertices.length; vindex < vcount; ++vindex) { + const local = this.geometry.vertices[vindex].clone(); + const world = this.localToWorld(local); + vertices.push(world); + } + + // Check distance to portal + const distance = this.portal.plane.distanceToPoint(this.worldToLocal(origin.clone())); + const close = distance < 1.0 && distance > -1.0; + + // If the portal is very close, use the portal vertices unedited; otherwise, clip the portal + // vertices by the provided frustum. + const clipped = close ? vertices : THREEUtil.clipVerticesByFrustum(vertices, frustum); + + // If clipping the portal vertices resulted in a polygon with fewer than 3 vertices, return + // null to indicate a new frustum couldn't be produced. + if (clipped.length < 3) { + return null; + } + + // Produce side planes for new frustum + for (let vindex = 0, vcount = clipped.length; vindex < vcount; ++vindex) { + const vertex1 = clipped[vindex]; + const vertex2 = clipped[(vindex + 1) % vcount]; + + const plane = new THREE.Plane().setFromCoplanarPoints(origin, vertex1, vertex2); + if (flip) plane.negate(); + planes.push(plane); + } + + // Copy the original far plane (index: last - 1) + const farPlaneIndex = frustum.planes.length - 2; + const farPlane = frustum.planes[farPlaneIndex]; + planes.push(farPlane); + + // Create a near plane matching the portal + const nearPlane = new THREE.Plane().setFromCoplanarPoints(clipped[0], clipped[1], clipped[2]); + if (flip) nearPlane.negate(); + planes.push(nearPlane); + + const newFrustum = { planes }; + + return newFrustum; + } + + /** + * Check if a given frustum contains or intersects with this portal view. + * + * @param frustum - Frustum object containing planes to check for portal inclusion / intersection + * + * @returns {Boolean} - Boolean indicating if the given frustum contained or intersected with + * this portal view + * + */ + intersectFrustum(frustum) { + const planes = frustum.planes; + const vertices = this.geometry.vertices; + + for (let pindex = 0, pcount = planes.length; pindex < pcount; ++pindex) { + const plane = planes[pindex]; + + if (!plane) { + continue; + } + + let inside = 0; + + for (let vindex = 0, vcount = vertices.length; vindex < vcount; ++vindex) { + const vertex = this.localToWorld(vertices[vindex].clone()); + const distance = plane.distanceToPoint(vertex); + + if (distance >= 0.0) { + inside++; + } + } + + if (inside === 0) { + return false; + } + } + + return true; + } + +} + +export default WMOPortalView; diff --git a/src/lib/pipeline/wmo/root/flags.js b/src/lib/pipeline/wmo/root/flags.js new file mode 100644 index 00000000..ebf4ce33 --- /dev/null +++ b/src/lib/pipeline/wmo/root/flags.js @@ -0,0 +1,6 @@ +const flags = { + SKIP_MOCV_ATTENUATION: 0x00001, + SKIP_ROOT_AMBIENT_COLOR: 0x00002 +}; + +export default flags; diff --git a/src/lib/pipeline/wmo/root/index.js b/src/lib/pipeline/wmo/root/index.js new file mode 100644 index 00000000..f2355fe0 --- /dev/null +++ b/src/lib/pipeline/wmo/root/index.js @@ -0,0 +1,176 @@ +import * as THREE from 'three'; + +import WMORootView from './view'; +import WMOPortal from '../portal'; +import WMOMaterial from '../material'; +import WMOMaterialDefinition from '../material/loader/definition'; + +class WMORoot { + + constructor(def) { + this.path = def.path; + + this.id = def.rootID; + this.header = def.header; + + this.groupInfo = def.groupInfo; + this.groupCount = def.groupCount; + this.interiorGroupCount = def.interiorGroupCount; + this.exteriorGroupCount = def.exteriorGroupCount; + + this.interiorGroupIndices = def.interiorGroupIndices; + this.exteriorGroupIndices = def.exteriorGroupIndices; + + this.doodadSets = def.doodadSets; + this.doodadEntries = def.doodadEntries; + + this.caches = { + material: new Map() + }; + + this.refCounts = { + material: new Map() + }; + + this.defs = { + material: new Map() + }; + + this.createBoundingBox(def.boundingBox); + + this.createMaterialDefs(def.materials, def.texturePaths); + + this.createPortals(def.portals, def.portalNormals, def.portalConstants, def.portalVertices); + + this.portalRefs = def.portalRefs; + } + + createView() { + return new WMORootView(this); + } + + dispose() { + for (const material of this.caches.material.values()) { + material.dispose(); + } + + this.caches = { + material: new Map() + }; + + this.refCounts = { + material: new Map() + }; + + this.defs = { + material: new Map() + }; + } + + // Because of the large number of reused texture paths, we create the material defs on the main + // thread to reduce the cost of transferring the definition off of the worker thread. + createMaterialDefs(materials, texturePaths) { + const defs = this.defs.material; + + for (let mindex = 0, mcount = materials.length; mindex < mcount; ++mindex) { + const data = materials[mindex]; + + const { flags, blendingMode, shaderID } = data; + const textures = []; + + for (let tindex = 0, tcount = data.textures.length; tindex < tcount; ++tindex) { + const textureData = data.textures[tindex]; + const texturePath = texturePaths[textureData.offset]; + + if (texturePath) { + textures.push({ path: texturePath }); + } + } + + const def = new WMOMaterialDefinition(mindex, flags, blendingMode, shaderID, textures); + + defs.set(mindex, def); + } + } + + loadMaterials(refs) { + const materials = []; + + for (let rindex = 0, rcount = refs.length; rindex < rcount; ++rindex) { + const ref = refs[rindex]; + const def = this.defs.material.get(ref.materialIndex).forRef(ref); + + const refCount = (this.refCounts.material.get(def.key) || 0) + 1; + this.refCounts.material.set(def.key, refCount); + + let material = this.caches.material.get(def.key); + + if (!material) { + material = new WMOMaterial(def); + this.caches.material.set(def.key, material); + } + + materials.push(material); + } + + return materials; + } + + unloadMaterial(material) { + const refCount = (this.refCounts.material.get(material.key) || 1) - 1; + + if (refCount <= 0) { + this.refCounts.material.delete(material.key); + this.caches.material.delete(material.key); + material.dispose(); + } else { + this.refCounts.material.set(material.key, refCount); + } + } + + doodadSet(doodadSet) { + const set = this.doodadSets[doodadSet]; + const { startIndex: start, doodadCount: count } = set; + + const entries = this.doodadEntries.slice(start, start + count); + + return { start, count, entries }; + } + + createPortals(defs, normals, constants, vertices) { + const portals = this.portals = []; + + const portalCount = defs.length; + + for (let index = 0; index < portalCount; ++index) { + const def = defs[index]; + + const vindex = def.vertexOffset * 3; + const vlen = def.vertexCount * 3; + + const nindex = index * 3; + const nlen = 3; + + const portal = new WMOPortal({ + index: index, + vertices: vertices.subarray(vindex, vindex + vlen), + normal: normals.subarray(nindex, nindex + nlen), + constant: constants[index] + }); + + portals.push(portal); + } + } + + createBoundingBox(def) { + const boundingBox = this.boundingBox = new THREE.Box3; + + const min = new THREE.Vector3(def.min[0], def.min[1], def.min[2]); + const max = new THREE.Vector3(def.max[0], def.max[1], def.max[2]); + + boundingBox.set(min, max); + } + +} + +export default WMORoot; diff --git a/src/lib/pipeline/wmo/root/loader/definition.js b/src/lib/pipeline/wmo/root/loader/definition.js new file mode 100644 index 00000000..857a3270 --- /dev/null +++ b/src/lib/pipeline/wmo/root/loader/definition.js @@ -0,0 +1,126 @@ +class WMORootDefinition { + + constructor(path, data) { + this.path = path; + this.rootID = data.MOHD.rootID; + + this.header = { + flags: data.MOHD.flags, + ambientColor: data.MOHD.ambientColor + }; + + this.groupInfo = data.MOGI.groups; + + this.materials = data.MOMT.materials; + this.texturePaths = data.MOTX.filenames; + + this.doodadSets = data.MODS.sets; + this.doodadEntries = data.MODD.doodads; + + this.summarizeGroups(data); + + this.createPortals(data); + this.createBoundingBox(data.MOHD); + } + + createBoundingBox(mohd) { + const boundingBox = this.boundingBox = {}; + + boundingBox.min = mohd.boundingBox.min; + boundingBox.max = mohd.boundingBox.max; + } + + createPortals(data) { + const portalCount = data.MOPT.portals.length; + const portalVertexCount = data.MOPV.vertices.length; + + this.portalRefs = data.MOPR.references; + + const portals = this.portals = []; + this.assignPortals(portalCount, data.MOPT, portals); + + const portalNormals = this.portalNormals = new Float32Array(3 * portalCount); + this.assignPortalNormals(portalCount, data.MOPT, portalNormals); + + const portalConstants = this.portalConstants = new Float32Array(1 * portalCount); + this.assignPortalConstants(portalCount, data.MOPT, portalConstants); + + const portalVertices = this.portalVertices = new Float32Array(3 * portalVertexCount); + this.assignPortalVertices(portalVertexCount, data.MOPV, portalVertices); + } + + assignPortals(portalCount, mopt, attribute) { + for (let index = 0; index < portalCount; ++index) { + const portal = mopt.portals[index]; + + attribute.push({ + vertexOffset: portal.vertexOffset, + vertexCount: portal.vertexCount + }); + } + } + + assignPortalNormals(portalCount, mopt, attribute) { + for (let index = 0; index < portalCount; ++index) { + const portal = mopt.portals[index]; + const normal = portal.plane.normal; + + attribute.set([normal[0], normal[1], normal[2]], index * 3); + } + } + + assignPortalConstants(portalCount, mopt, attribute) { + for (let index = 0; index < portalCount; ++index) { + const portal = mopt.portals[index]; + const constant = portal.plane.constant; + + attribute.set([constant], index); + } + } + + assignPortalVertices(vertexCount, mopv, attribute) { + for (let index = 0; index < vertexCount; ++index) { + const vertex = mopv.vertices[index]; + + attribute.set([vertex[0], vertex[1], vertex[2]], index * 3); + } + } + + summarizeGroups(data) { + this.groupCount = data.MOGI.groups.length; + this.interiorGroupCount = 0; + this.exteriorGroupCount = 0; + + this.interiorGroupIndices = []; + this.exteriorGroupIndices = []; + + // Separate group indices by interior/exterior flag. This allows us to queue exterior groups to + // load before interior groups. + for (let index = 0; index < this.groupCount; ++index) { + const group = data.MOGI.groups[index]; + + if (group.interior) { + this.interiorGroupIndices.push(index); + this.interiorGroupCount++; + } else { + this.exteriorGroupIndices.push(index); + this.exteriorGroupCount++; + } + } + } + + // Returns an array of references to typed arrays that we'd like to transfer across worker + // boundaries. + get transferable() { + const list = []; + + list.push(this.portalNormals.buffer); + list.push(this.portalConstants.buffer); + list.push(this.portalVertices.buffer); + + return list; + } + +} + +export default WMORootDefinition; diff --git a/src/lib/pipeline/wmo/blueprint.js b/src/lib/pipeline/wmo/root/loader/index.js similarity index 50% rename from src/lib/pipeline/wmo/blueprint.js rename to src/lib/pipeline/wmo/root/loader/index.js index 3831b24f..c086d3ac 100644 --- a/src/lib/pipeline/wmo/blueprint.js +++ b/src/lib/pipeline/wmo/root/loader/index.js @@ -1,11 +1,11 @@ -import WorkerPool from '../worker/pool'; -import WMO from './'; +import WorkerPool from '../../../worker/pool'; +import WMORoot from '../'; -class WMOBlueprint { +class WMORootLoader { static cache = new Map(); - static references = new Map(); + static refCounts = new Map(); static pendingUnload = new Set(); static unloaderRunning = false; @@ -14,7 +14,7 @@ class WMOBlueprint { static load(rawPath) { const path = rawPath.toUpperCase(); - // Prevent unintended unloading. + // Intent to load overrides pending unload. if (this.pendingUnload.has(path)) { this.pendingUnload.delete(path); } @@ -26,41 +26,44 @@ class WMOBlueprint { } // Keep track of references. - let refCount = this.references.get(path) || 0; - ++refCount; - this.references.set(path, refCount); + const refCount = (this.refCounts.get(path) || 0) + 1; + this.refCounts.set(path, refCount); if (!this.cache.has(path)) { - this.cache.set(path, WorkerPool.enqueue('WMO', path).then((args) => { - const [data] = args; + const worker = WorkerPool.enqueue('WMORoot', path); - return new WMO(path, data); - })); + const promise = worker.then((def) => { + return new WMORoot(def); + }); + + this.cache.set(path, promise); } - return this.cache.get(path).then((wmo) => { - return wmo.clone(); - }); + return this.cache.get(path); } - static unload(wmo) { - const path = wmo.path.toUpperCase(); + static unload(root) { + const path = root.path.toUpperCase(); - let refCount = this.references.get(path) || 1; - - --refCount; + const refCount = (this.refCounts.get(path) || 1) - 1; if (refCount === 0) { this.pendingUnload.add(path); } else { - this.references.set(path, refCount); + this.refCounts.set(path, refCount); } } static backgroundUnload() { this.pendingUnload.forEach((path) => { + if (this.cache.has(path)) { + this.cache.get(path).then((root) => { + root.dispose(); + }); + } + this.cache.delete(path); - this.references.delete(path); + this.refCounts.delete(path); this.pendingUnload.delete(path); }); @@ -69,4 +72,4 @@ class WMOBlueprint { } -export default WMOBlueprint; +export default WMORootLoader; diff --git a/src/lib/pipeline/wmo/loader.js b/src/lib/pipeline/wmo/root/loader/worker.js similarity index 68% rename from src/lib/pipeline/wmo/loader.js rename to src/lib/pipeline/wmo/root/loader/worker.js index b5aba3d6..3a71bfc7 100644 --- a/src/lib/pipeline/wmo/loader.js +++ b/src/lib/pipeline/wmo/root/loader/worker.js @@ -1,7 +1,8 @@ import { DecodeStream } from 'blizzardry/lib/restructure'; import WMO from 'blizzardry/lib/wmo'; -import Loader from '../../net/loader'; +import Loader from '../../../../net/loader'; +import WMORootDefinition from './definition'; const loader = new Loader(); @@ -9,7 +10,11 @@ export default function(path) { return loader.load(path).then((raw) => { const buffer = new Buffer(new Uint8Array(raw)); const stream = new DecodeStream(buffer); + const data = WMO.decode(stream); - return data; + + const def = new WMORootDefinition(path, data); + + return def; }); } diff --git a/src/lib/pipeline/wmo/root/view.js b/src/lib/pipeline/wmo/root/view.js new file mode 100644 index 00000000..17f54a77 --- /dev/null +++ b/src/lib/pipeline/wmo/root/view.js @@ -0,0 +1,19 @@ +import * as THREE from 'three'; + +class WMORootView extends THREE.Group { + + constructor(root) { + super(); + + this.matrixAutoUpdate = false; + + this.root = root; + } + + clone() { + return this.root.createView(); + } + +} + +export default WMORootView; diff --git a/src/lib/pipeline/worker/index.js b/src/lib/pipeline/worker/index.js index 43b4b93c..b921a9c2 100644 --- a/src/lib/pipeline/worker/index.js +++ b/src/lib/pipeline/worker/index.js @@ -2,8 +2,8 @@ import ADT from '../adt/loader'; import DBC from '../dbc/loader'; import M2 from '../m2/loader'; import WDT from '../wdt/loader'; -import WMO from '../wmo/loader'; -import WMOGroup from '../wmo/group/loader'; +import WMORoot from '../wmo/root/loader/worker'; +import WMOGroup from '../wmo/group/loader/worker'; const worker = self; @@ -12,12 +12,19 @@ const loaders = { DBC, M2, WDT, - WMO, + WMORoot, WMOGroup }; -const fulfill = function(type, result) { - worker.postMessage([type].concat(result)); +const fulfill = function(success, value) { + const result = { + success: success, + value: value + }; + + const transferable = value.transferable || []; + + worker.postMessage(result, transferable); }; const resolve = function(value) { diff --git a/src/lib/pipeline/worker/thread.js b/src/lib/pipeline/worker/thread.js index b91f826a..416c5c08 100644 --- a/src/lib/pipeline/worker/thread.js +++ b/src/lib/pipeline/worker/thread.js @@ -24,12 +24,14 @@ class Thread { } _onMessage(event) { - const [success, ...args] = event.data; - if (success) { - this.task.resolve(args); + const result = event.data; + + if (result.success) { + this.task.resolve(result.value); } else { - this.task.reject(args); + this.task.reject(result.value); } + this.task = null; } diff --git a/src/lib/utils/bsp-tree.js b/src/lib/utils/bsp-tree.js new file mode 100644 index 00000000..b746617c --- /dev/null +++ b/src/lib/utils/bsp-tree.js @@ -0,0 +1,189 @@ +import * as THREE from 'three'; + +import THREEUtil from './three-util'; + +class BSPTree { + + constructor(nodes, planeIndices, faceIndices, vertices) { + this.nodes = nodes; + + this.indices = { + plane: planeIndices, + face: faceIndices + }; + + this.vertices = vertices; + } + + query(subject, startingNodeIndex) { + const leafIndices = []; + + this.queryBox(subject, startingNodeIndex, leafIndices); + + return leafIndices; + } + + queryBox(box, nodeIndex, leafIndices) { + if (nodeIndex === -1) { + return; + } + + const node = this.nodes[nodeIndex]; + + if (node.planeType === 4) { + leafIndices.push(nodeIndex); + return; + } + + const leftPlane = new THREE.Plane; + const rightPlane = new THREE.Plane; + + if (node.planeType === 0) { + leftPlane.setComponents(-1.0, 0.0, 0.0, node.distance); + rightPlane.setComponents(1.0, 0.0, 0.0, -node.distance); + } else if (node.planeType === 1) { + leftPlane.setComponents(0.0, -1.0, 0.0, node.distance); + rightPlane.setComponents(0.0, 1.0, 0.0, -node.distance); + } else if (node.planeType === 2) { + leftPlane.setComponents(0.0, 0.0, -1.0, node.distance); + rightPlane.setComponents(0.0, 0.0, 1.0, -node.distance); + } + + const includeLeft = THREEUtil.planeContainsBox(leftPlane, box); + const includeRight = THREEUtil.planeContainsBox(rightPlane, box); + + if (includeLeft) { + this.queryBox(box, node.children[0], leafIndices); + } + + if (includeRight) { + this.queryBox(box, node.children[1], leafIndices); + } + } + + calculateZRange(point, leafIndices) { + let rangeMin = null; + let rangeMax = null; + + for (let lindex = 0, lcount = leafIndices.length; lindex < lcount; ++lindex) { + const node = this.nodes[leafIndices[lindex]]; + + const pbegin = node.firstFace; + const pend = node.firstFace + node.faceCount; + + for (let pindex = pbegin; pindex < pend; ++pindex) { + const vindex1 = this.indices.face[3 * this.indices.plane[pindex] + 0]; + const vindex2 = this.indices.face[3 * this.indices.plane[pindex] + 1]; + const vindex3 = this.indices.face[3 * this.indices.plane[pindex] + 2]; + + const vertex1 = new THREE.Vector3( + this.vertices[3 * vindex1 + 0], + this.vertices[3 * vindex1 + 1], + this.vertices[3 * vindex1 + 2] + ); + + const vertex2 = new THREE.Vector3( + this.vertices[3 * vindex2 + 0], + this.vertices[3 * vindex2 + 1], + this.vertices[3 * vindex2 + 2] + ); + + const vertex3 = new THREE.Vector3( + this.vertices[3 * vindex3 + 0], + this.vertices[3 * vindex3 + 1], + this.vertices[3 * vindex3 + 2] + ); + + const minX = Math.min(vertex1.x, vertex2.x, vertex3.x); + const maxX = Math.max(vertex1.x, vertex2.x, vertex3.x); + + const minY = Math.min(vertex1.y, vertex2.y, vertex3.y); + const maxY = Math.max(vertex1.y, vertex2.y, vertex3.y); + + const pointInBoundsXY = + point.x >= minX && point.x <= maxX && + point.y >= minY && point.y <= maxY; + + if (!pointInBoundsXY) { + continue; + } + + const triangle = new THREE.Triangle(vertex1, vertex2, vertex3); + + const z = this.calculateZFromTriangleAndXY(triangle, point.x, point.y); + + const bary = triangle.barycoordFromPoint(new THREE.Vector3(point.x, point.y, z)); + + const baryInBounds = bary.x >= 0 && bary.y >= 0 && bary.z >= 0; + + if (!baryInBounds) { + continue; + } + + if (z < point.z && (rangeMin === null || z < rangeMin)) { + rangeMin = z; + } + + if (z > point.z && (rangeMax === null || z > rangeMax)) { + rangeMax = z; + } + } + } + + if (rangeMax - rangeMin < 0.001) { + rangeMax = null; + } + + return [rangeMin, rangeMax]; + } + + calculateZFromTriangleAndXY(triangle, x, y) { + const p1 = triangle.a; + const p2 = triangle.b; + const p3 = triangle.c; + + const det = (p2.y - p3.y) * (p1.x - p3.x) + (p3.x - p2.x) * (p1.y - p3.y); + + if (det > -0.001 && det < 0.001) { + return Math.min(p1.x, p2.x, p3.x); + } + + const l1 = ((p2.y - p3.y) * (x - p3.x) + (p3.x - p2.x) * (y - p3.y)) / det; + const l2 = ((p3.y - p1.y) * (x - p3.x) + (p1.x - p3.x) * (y - p3.y)) / det; + const l3 = 1.0 - l1 - l2; + + return l1 * p1.z + l2 * p2.z + l3 * p3.z; + } + + queryBoundedPoint(point, bounding) { + const epsilon = 0.2; + + // Define a small bounding box for point + const box = new THREE.Box3(); + box.min.set(point.x - epsilon, point.y - epsilon, bounding.min.z); + box.max.set(point.x + epsilon, point.y + epsilon, bounding.max.z); + + // Query BSP tree + const leafIndices = this.query(box, 0); + + // If no leaves were found, there is no valid result + if (leafIndices.length === 0) { + return null; + } + + // Determine upper and lower Z bounds of leaves + const zRange = this.calculateZRange(point, leafIndices); + const minZ = zRange[0]; + const maxZ = zRange[1]; + + return { + z: { + min: minZ, + max: maxZ + } + }; + } + +} + +export default BSPTree; diff --git a/src/lib/game/world/content-queue.js b/src/lib/utils/content-queue.js similarity index 72% rename from src/lib/game/world/content-queue.js rename to src/lib/utils/content-queue.js index 6d4b6bb4..6361af09 100644 --- a/src/lib/game/world/content-queue.js +++ b/src/lib/utils/content-queue.js @@ -9,6 +9,8 @@ class ContentQueue { this.queue = new Map(); + this.previousTimestamp = performance.now(); + this.schedule = ::this.schedule; this.run = ::this.run; @@ -39,16 +41,21 @@ class ContentQueue { } schedule() { - setTimeout(this.run, this.interval); + requestAnimationFrame(this.run); } - run() { - let count = 0; - const max = Math.min(this.queue.size * this.workFactor, this.minWork); + run(now) { + if (now - this.previousTimestamp < this.interval) { + this.schedule(); + return; + } + + this.previousTimestamp = now; - for (const entry of this.queue) { - const [key, job] = entry; + let count = 0; + const max = Math.max(this.queue.size * this.workFactor, this.minWork); + for (const [key, job] of this.queue) { this.processor(job); this.queue.delete(key); diff --git a/src/lib/utils/math-util.js b/src/lib/utils/math-util.js new file mode 100644 index 00000000..5a64ec71 --- /dev/null +++ b/src/lib/utils/math-util.js @@ -0,0 +1,10 @@ +class MathUtil { + + // Clamps value to range + static clamp(value, min, max) { + return Math.min(Math.max(value, min), max); + } + +} + +export default MathUtil; diff --git a/src/lib/utils/three-util.js b/src/lib/utils/three-util.js new file mode 100644 index 00000000..477fed93 --- /dev/null +++ b/src/lib/utils/three-util.js @@ -0,0 +1,86 @@ +import * as THREE from 'three'; + +class THREEUtil { + + static planeContainsBox(plane, box) { + const p1 = new THREE.Vector3(); + const p2 = new THREE.Vector3(); + + p1.x = plane.normal.x > 0 ? box.min.x : box.max.x; + p2.x = plane.normal.x > 0 ? box.max.x : box.min.x; + p1.y = plane.normal.y > 0 ? box.min.y : box.max.y; + p2.y = plane.normal.y > 0 ? box.max.y : box.min.y; + p1.z = plane.normal.z > 0 ? box.min.z : box.max.z; + p2.z = plane.normal.z > 0 ? box.max.z : box.min.z; + + const d1 = plane.distanceToPoint(p1); + const d2 = plane.distanceToPoint(p2); + + if (d1 < 0 && d2 < 0) { + return false; + } + + return true; + } + + static frustumContainsBox(frustum, box) { + for (let pindex = 0, pcount = frustum.planes.length; pindex < pcount; ++pindex) { + const plane = frustum.planes[pindex]; + + if (!this.planeContainsBox(plane, box)) { + return false; + } + } + + return true; + } + + static clipVerticesByPlane(vertices, plane) { + const clipped = []; + + for (let vindex = 0, vcount = vertices.length; vindex < vcount; ++vindex) { + const v1 = vertices[vindex]; + const v2 = vertices[(vindex + 1) % vcount]; + + const d1 = plane.distanceToPoint(v1); + const d2 = plane.distanceToPoint(v2); + + const line = new THREE.Line3(v1, v2); + const intersection = d1 / (d1 - d2); + + if (d1 < 0 && d2 < 0) { + continue; + } else if (d1 > 0 && d2 > 0) { + clipped.push(v1); + } else if (d1 > 0) { + clipped.push(v1); + clipped.push(line.at(intersection)); + } else { + clipped.push(line.at(intersection)); + } + } + + return clipped; + } + + static clipVerticesByFrustum(vertices, frustum) { + const planes = frustum.planes; + + let clipped = vertices; + + for (let pindex = 0, pcount = planes.length; pindex < pcount; ++pindex) { + const plane = planes[pindex]; + + if (!plane) { + continue; + } + + clipped = this.clipVerticesByPlane(clipped, plane); + } + + return clipped; + } + +} + +export default THREEUtil;