diff --git a/package-lock.json b/package-lock.json index cc836b4..3572855 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.14.0", "license": "MIT", "dependencies": { - "@wowserhq/format": "^0.11.0" + "@wowserhq/format": "^0.12.0" }, "devDependencies": { "@commitlint/config-conventional": "^18.4.3", @@ -2347,9 +2347,9 @@ } }, "node_modules/@wowserhq/format": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@wowserhq/format/-/format-0.11.0.tgz", - "integrity": "sha512-kZRztfgwhoIyp8ZITIYBZer3MQTW1cwg4spKgqOI+068TrZSu9Wte8RGCxnYvfycGVYyHwT687BocDiYDJO03w==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@wowserhq/format/-/format-0.12.0.tgz", + "integrity": "sha512-ddEXUOQJNfufCe58S21GjxtJhmQY/bkgHiCUNjvHeb/LwIQYnJEJyoTLVBTL1vQ9Br7pP36RohfHN56HZ418ZQ==", "dependencies": { "@wowserhq/io": "^2.0.2", "gl-matrix": "^3.4.3" diff --git a/package.json b/package.json index b9cc683..5951259 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "three.js" ], "dependencies": { - "@wowserhq/format": "^0.11.0" + "@wowserhq/format": "^0.12.0" }, "peerDependencies": { "three": "^0.160.0" diff --git a/src/lib/FormatManager.ts b/src/lib/FormatManager.ts deleted file mode 100644 index cd5f142..0000000 --- a/src/lib/FormatManager.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { AssetHost, loadAsset, normalizePath } from './asset.js'; - -interface FormatConstructor { - new (...args: any[]): T; -} - -interface Format { - load: (data: ArrayBuffer) => T; -} - -type FormatManagerOptions = { - host: AssetHost; -}; - -class FormatManager { - #host: AssetHost; - - #loaded = new Map(); - #loading = new Map>(); - - constructor(options: FormatManagerOptions) { - this.#host = options.host; - } - - get>( - path: string, - FormatClass: FormatConstructor, - ...formatConstructorArgs: any[] - ): Promise { - const cacheKey = [ - normalizePath(path), - FormatClass.prototype.constructor.name, - formatConstructorArgs, - ].join(':'); - - const loaded = this.#loaded.get(cacheKey); - if (loaded) { - return Promise.resolve(loaded); - } - - const alreadyLoading = this.#loading.get(cacheKey); - if (alreadyLoading) { - return alreadyLoading; - } - - const loading = this.#load(cacheKey, path, FormatClass, formatConstructorArgs); - this.#loading.set(cacheKey, loading); - - return loading; - } - - async #load>( - cacheKey: string, - path: string, - FormatClass: FormatConstructor, - formatConstructorArgs: any[], - ): Promise { - let instance: T; - try { - const data = await loadAsset(this.#host, path); - instance = new FormatClass(...formatConstructorArgs).load(data); - - this.#loaded.set(cacheKey, instance); - } finally { - this.#loading.delete(cacheKey); - } - - return instance; - } -} - -export default FormatManager; - -export { FormatManager }; diff --git a/src/lib/SceneWorker.ts b/src/lib/SceneWorker.ts deleted file mode 100644 index 53ee456..0000000 --- a/src/lib/SceneWorker.ts +++ /dev/null @@ -1,112 +0,0 @@ -const workerMessageHandler = (event: MessageEvent) => { - const id: number = event.data.id; - const functionName: string = event.data.functionName; - const functionArgs: any[] = event.data.functionArgs; - const transfer: Transferable[] = []; - - try { - const fn = self[functionName]; - const result = fn(...functionArgs); - - if (ArrayBuffer.isView(result)) { - transfer.push(result.buffer); - } else if (result instanceof ArrayBuffer) { - transfer.push(result); - } - - self.postMessage({ id, result, success: true }, { transfer }); - } catch (error) { - self.postMessage({ id, result: error, success: false }); - } -}; - -const workerSetConstants = (constants: Record) => { - for (const [key, value] of Object.entries(constants)) { - self[key] = value; - } -}; - -const contextToInlineUrl = (context: Record) => { - const initializer = []; - for (const [rawKey, rawValue] of Object.entries(context)) { - let key = `'${rawKey.toString()}'`; - - let value = rawValue; - if (typeof rawValue === 'function') { - value = rawValue.toString(); - } else if (typeof rawValue === 'string' || typeof rawValue === 'object') { - value = JSON.stringify(rawValue); - } - - initializer.push(`self[${key}] = `, value, ';', '\n\n'); - } - - initializer.push(`self['setConstants'] = `, workerSetConstants.toString(), ';', '\n\n'); - initializer.push(`self['onmessage'] = `, workerMessageHandler.toString(), ';', '\n'); - - const blob = new Blob(initializer, { type: 'text/javascript' }); - return URL.createObjectURL(blob); -}; - -class SceneWorker { - #worker: Worker; - #pending = new Map void; reject: (reason: any) => void }>(); - #nextId = 0; - #constants: Record; - #initialized = false; - #initializing: Promise; - - constructor(name: string, context: Record, constants: Record) { - this.#worker = new Worker(contextToInlineUrl(context), { name }); - this.#constants = constants; - - this.#worker.onmessage = (event: MessageEvent) => { - const id: number = event.data.id; - const pending = this.#pending.get(id); - - if (event.data.success) { - pending.resolve(event.data.result); - } else { - pending.reject(event.data.result); - } - - this.#pending.delete(id); - }; - } - - async call(name: string, ...args: any[]): Promise { - if (!this.#initialized) { - if (!this.#initializing) { - this.#initializing = this.#initialize(); - } - - await this.#initializing; - } - - return this.#call(name, args); - } - - #call(name: string, args: any[]): Promise { - const id = this.#nextId++; - - const promise = new Promise((resolve, reject) => { - this.#pending.set(id, { resolve, reject }); - }); - - this.#worker.postMessage({ id, functionName: name, functionArgs: args }); - - return promise; - } - - async #initialize() { - if (this.#constants) { - await this.#call('setConstants', [this.#constants]); - } - - this.#initialized = true; - this.#initializing = null; - } -} - -export default SceneWorker; -export { SceneWorker }; diff --git a/src/lib/index.ts b/src/lib/index.ts index 27068e6..533950e 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,11 +1,6 @@ -export * from './util.js'; -export * from './controls/OrbitControls.js'; export * from './controls/MapControls.js'; -export * from './FormatManager.js'; -export * from './SceneWorker.js'; +export * from './controls/OrbitControls.js'; +export * from './map/MapManager.js'; export * from './model/ModelManager.js'; -export * from './terrain/TerrainManager.js'; -export * from './terrain/TerrainMesh.js'; -export * from './terrain/TerrainMaterial.js'; export * from './texture/TextureManager.js'; -export * from './map/MapManager.js'; +export * from './util.js'; diff --git a/src/lib/map/DoodadManager.ts b/src/lib/map/DoodadManager.ts index 2e8f332..6bd8247 100644 --- a/src/lib/map/DoodadManager.ts +++ b/src/lib/map/DoodadManager.ts @@ -1,8 +1,8 @@ import * as THREE from 'three'; -import { MapArea } from '@wowserhq/format'; import ModelManager from '../model/ModelManager.js'; import TextureManager from '../texture/TextureManager.js'; import { AssetHost } from '../asset.js'; +import { MapAreaSpec } from './loader/types.js'; type DoodadManagerOptions = { host: AssetHost; @@ -22,7 +22,7 @@ class DoodadManager { }); } - async getArea(area: MapArea) { + async getArea(area: MapAreaSpec) { const group = new THREE.Group(); group.name = 'doodads'; diff --git a/src/lib/map/MapManager.ts b/src/lib/map/MapManager.ts index 7134839..32ea7c1 100644 --- a/src/lib/map/MapManager.ts +++ b/src/lib/map/MapManager.ts @@ -1,16 +1,16 @@ import * as THREE from 'three'; -import { Map, MapArea, MAP_CHUNK_HEIGHT, MAP_AREA_COUNT_Y } from '@wowserhq/format'; -import FormatManager from '../FormatManager.js'; -import TerrainManager from '../terrain/TerrainManager.js'; +import { Map, MAP_CHUNK_HEIGHT, MAP_AREA_COUNT_Y } from '@wowserhq/format'; +import TerrainManager from './terrain/TerrainManager.js'; import TextureManager from '../texture/TextureManager.js'; import DoodadManager from './DoodadManager.js'; import { AssetHost } from '../asset.js'; +import MapLoader from './loader/MapLoader.js'; +import { MapAreaSpec, MapSpec } from './loader/types.js'; const DEFAULT_VIEW_DISTANCE = 1277.0; type MapManagerOptions = { host: AssetHost; - formatManager?: FormatManager; textureManager?: TextureManager; viewDistance?: number; }; @@ -18,16 +18,16 @@ type MapManagerOptions = { class MapManager { #mapName: string; #mapDir: string; - #map: Map; + #map: MapSpec; - #loadingAreas = new globalThis.Map>(); - #loadedAreas = new globalThis.Map(); + #loader: MapLoader; + #loadingAreas = new globalThis.Map>(); + #loadedAreas = new globalThis.Map(); #root: THREE.Group; #terrainGroups = new globalThis.Map(); #doodadGroups = new globalThis.Map(); - #formatManager: FormatManager; #textureManager: TextureManager; #terrainManager: TerrainManager; #doodadManager: DoodadManager; @@ -49,8 +49,8 @@ class MapManager { this.#viewDistance = options.viewDistance; } - this.#formatManager = new FormatManager({ host: options.host }); this.#textureManager = options.textureManager ?? new TextureManager({ host: options.host }); + this.#loader = new MapLoader({ host: options.host }); this.#terrainManager = new TerrainManager({ host: options.host, @@ -250,7 +250,7 @@ class MapManager { async #loadMap() { const mapPath = `${this.#mapDir}/${this.#mapName}.wdt`; - this.#map = await this.#formatManager.get(mapPath, Map); + this.#map = await this.#loader.loadMapSpec(mapPath); } #getArea(areaId: number) { @@ -272,13 +272,15 @@ class MapManager { async #loadArea(areaId: number) { const { areaX, areaY } = this.#getAreaIndex(areaId); + + const mapPath = `${this.#mapDir}/${this.#mapName}.wdt`; const areaPath = `${this.#mapDir}/${this.#mapName}_${areaY}_${areaX}.adt`; - const area = await this.#formatManager.get(areaPath, MapArea, this.#map.layerSplatDepth); + const areaSpec = await this.#loader.loadAreaSpec(mapPath, areaPath); - this.#loadedAreas.set(areaId, area); + this.#loadedAreas.set(areaId, areaSpec); this.#loadingAreas.delete(areaId); - return area; + return areaSpec; } } diff --git a/src/lib/map/loader/MapLoader.ts b/src/lib/map/loader/MapLoader.ts new file mode 100644 index 0000000..354012b --- /dev/null +++ b/src/lib/map/loader/MapLoader.ts @@ -0,0 +1,29 @@ +import SceneWorkerController from '../../worker/SceneWorkerController.js'; +import { MapSpec, MapAreaSpec } from './types.js'; +import { AssetHost } from '../../asset.js'; + +const createWorker = () => + new Worker(new URL('./worker.js', import.meta.url), { + name: 'map-loader', + type: 'module', + }); + +type MapLoaderOptions = { + host: AssetHost; +}; + +class MapLoader extends SceneWorkerController { + constructor(options: MapLoaderOptions) { + super(createWorker, { host: options.host }); + } + + loadMapSpec(mapPath: string): Promise { + return this.request('loadMapSpec', mapPath); + } + + loadAreaSpec(mapPath: string, areaPath: string): Promise { + return this.request('loadAreaSpec', mapPath, areaPath); + } +} + +export default MapLoader; diff --git a/src/lib/map/loader/MapLoaderWorker.ts b/src/lib/map/loader/MapLoaderWorker.ts new file mode 100644 index 0000000..42930d3 --- /dev/null +++ b/src/lib/map/loader/MapLoaderWorker.ts @@ -0,0 +1,155 @@ +import { + Map, + MAP_LAYER_SPLAT_X, + MAP_LAYER_SPLAT_Y, + MapArea, + MapChunk, + MapLayer, +} from '@wowserhq/format'; +import { + createTerrainIndexBuffer, + createTerrainVertexBuffer, + mergeTerrainLayerSplats, +} from './util.js'; +import { MapAreaSpec, TerrainSpec } from './types.js'; +import { AssetHost, loadAsset, normalizePath } from '../../asset.js'; +import SceneWorker from '../../worker/SceneWorker.js'; + +type MapLoaderWorkerOptions = { + host: AssetHost; +}; + +class MapLoaderWorker extends SceneWorker { + #host: AssetHost; + + #maps = new globalThis.Map(); + + initialize(options: MapLoaderWorkerOptions) { + this.#host = options.host; + } + + async loadMapSpec(mapPath: string) { + const map = await this.#loadMap(mapPath); + + const spec = { + availableAreas: map.availableAreas, + }; + + return spec; + } + + async loadAreaSpec(mapPath: string, areaPath: string) { + const map = await this.#loadMap(mapPath); + + const areaData = await loadAsset(this.#host, areaPath); + const area = new MapArea(map.layerSplatDepth).load(areaData); + + const transfer = []; + + const terrainSpecs: TerrainSpec[] = []; + for (const chunk of area.chunks) { + if (chunk.layers.length === 0) { + continue; + } + + const terrainSpec = { + position: chunk.position, + geometry: this.#createTerrainGeometrySpec(chunk), + material: this.#createTerrainMaterialSpec(chunk), + }; + + terrainSpecs.push(terrainSpec); + + transfer.push(terrainSpec.geometry.vertexBuffer); + transfer.push(terrainSpec.geometry.indexBuffer); + + if (terrainSpec.material.splat) { + transfer.push(terrainSpec.material.splat.data.buffer); + } + } + + const spec: MapAreaSpec = { + terrain: terrainSpecs, + doodadDefs: area.doodadDefs.map((def) => ({ + id: def.id, + name: def.name, + position: def.position, + rotation: def.rotation, + scale: def.scale, + })), + }; + + return [spec, transfer]; + } + + async #loadMap(mapPath) { + const refId = normalizePath(mapPath); + + const existingMap = this.#maps.get(refId); + if (existingMap) { + return existingMap; + } + + const mapData = await loadAsset(this.#host, mapPath); + const map = new Map().load(mapData); + + this.#maps.set(refId, map); + + return map; + } + + #createTerrainGeometrySpec(chunk: MapChunk) { + const vertexBuffer = createTerrainVertexBuffer(chunk.vertexHeights, chunk.vertexNormals); + const indexBuffer = createTerrainIndexBuffer(chunk.holes); + + return { + vertexBuffer, + indexBuffer, + }; + } + + #createTerrainMaterialSpec(chunk: MapChunk) { + const splat = this.#createTerrainSplatSpec(chunk.layers); + const layers = chunk.layers.map((layer) => ({ + effectId: layer.effectId, + texturePath: layer.texture, + })); + + return { + layers, + splat, + }; + } + + #createTerrainSplatSpec(layers: MapLayer[]) { + // No splat (0 or 1 layer) + + if (layers.length <= 1) { + return null; + } + + // Single splat (2 layers) + + if (layers.length === 2) { + return { + width: MAP_LAYER_SPLAT_X, + height: MAP_LAYER_SPLAT_Y, + data: layers[1].splat, + channels: 1, + }; + } + + // Multiple splats (3+ layers) + + const layerSplats = layers.slice(1).map((layer) => layer.splat); + const mergedSplat = mergeTerrainLayerSplats(layerSplats, MAP_LAYER_SPLAT_X, MAP_LAYER_SPLAT_Y); + return { + width: MAP_LAYER_SPLAT_X, + height: MAP_LAYER_SPLAT_Y, + data: mergedSplat, + channels: 4, + }; + } +} + +export default MapLoaderWorker; diff --git a/src/lib/terrain/worker/constants.ts b/src/lib/map/loader/const.ts similarity index 90% rename from src/lib/terrain/worker/constants.ts rename to src/lib/map/loader/const.ts index 0554f07..b58c764 100644 --- a/src/lib/terrain/worker/constants.ts +++ b/src/lib/map/loader/const.ts @@ -8,7 +8,7 @@ import { MAP_CHUNK_WIDTH, } from '@wowserhq/format'; -const createDefaultTerrainVertexBuffer = () => { +const DEFAULT_TERRAIN_VERTEX_BUFFER = (() => { // Vertex coordinates for x-axis (forward axis) const vxe = new Float32Array(MAP_CHUNK_FACE_COUNT_X + 1); const vxo = new Float32Array(MAP_CHUNK_FACE_COUNT_X); @@ -55,10 +55,6 @@ const createDefaultTerrainVertexBuffer = () => { } return vertexBuffer; -}; +})(); -const constants = { - DEFAULT_TERRAIN_VERTEX_BUFFER: createDefaultTerrainVertexBuffer(), -}; - -export default constants; +export { DEFAULT_TERRAIN_VERTEX_BUFFER }; diff --git a/src/lib/map/loader/types.ts b/src/lib/map/loader/types.ts new file mode 100644 index 0000000..492039e --- /dev/null +++ b/src/lib/map/loader/types.ts @@ -0,0 +1,46 @@ +type MapSpec = { + availableAreas: Uint8Array; +}; + +type MapDoodadDefSpec = { + id: number; + name: string; + position: Float32Array; + rotation: Float32Array; + scale: number; +}; + +type TerrainLayerSpec = { + texturePath: string; + effectId: number; +}; + +type TerrainSplatSpec = { + data: Uint8Array; + width: number; + height: number; + channels: number; +}; + +type TerrainMaterialSpec = { + splat: TerrainSplatSpec; + layers: TerrainLayerSpec[]; +}; + +type TerrainGeometrySpec = { + vertexBuffer: ArrayBuffer; + indexBuffer: ArrayBuffer; +}; + +type TerrainSpec = { + position: Float32Array; + geometry: TerrainGeometrySpec; + material: TerrainMaterialSpec; +}; + +type MapAreaSpec = { + terrain: TerrainSpec[]; + doodadDefs: MapDoodadDefSpec[]; +}; + +export { MapSpec, MapAreaSpec, TerrainSpec }; diff --git a/src/lib/map/loader/util.ts b/src/lib/map/loader/util.ts new file mode 100644 index 0000000..92d750a --- /dev/null +++ b/src/lib/map/loader/util.ts @@ -0,0 +1,145 @@ +import { + MAP_CHUNK_FACE_COUNT_X, + MAP_CHUNK_FACE_COUNT_Y, + MAP_CHUNK_HEIGHT, + MAP_CHUNK_VERTEX_COUNT, + MAP_CHUNK_VERTEX_STEP_X, + MAP_CHUNK_VERTEX_STEP_Y, + MAP_CHUNK_WIDTH, +} from '@wowserhq/format'; +import { DEFAULT_TERRAIN_VERTEX_BUFFER } from './const.js'; + +const isTerrainHole = (holes: number, x: number, y: number) => { + const column = (y / 2) | 0; + const row = (x / 2) | 0; + const hole = 1 << (column * 4 + row); + + return (hole & holes) !== 0; +}; + +const createDefaultTerrainVertexBuffer = () => { + // Vertex coordinates for x-axis (forward axis) + const vxe = new Float32Array(MAP_CHUNK_FACE_COUNT_X + 1); + const vxo = new Float32Array(MAP_CHUNK_FACE_COUNT_X); + for (let i = 0; i < MAP_CHUNK_FACE_COUNT_X; i++) { + const vx = -(i * MAP_CHUNK_VERTEX_STEP_X); + vxe[i] = vx; + vxo[i] = vx - MAP_CHUNK_VERTEX_STEP_X / 2.0; + } + vxe[MAP_CHUNK_FACE_COUNT_X] = -MAP_CHUNK_HEIGHT; + + // Vertex coordinates for y-axis (right axis) + const vye = new Float32Array(MAP_CHUNK_FACE_COUNT_Y + 1); + const vyo = new Float32Array(MAP_CHUNK_FACE_COUNT_Y); + for (let i = 0; i < MAP_CHUNK_FACE_COUNT_Y; i++) { + const vy = -(i * MAP_CHUNK_VERTEX_STEP_Y); + vye[i] = vy; + vyo[i] = vy - MAP_CHUNK_VERTEX_STEP_Y / 2.0; + } + vye[MAP_CHUNK_FACE_COUNT_Y] = -MAP_CHUNK_WIDTH; + + const vertexBuffer = new ArrayBuffer(MAP_CHUNK_VERTEX_COUNT * 16); + const vertexBufferView = new DataView(vertexBuffer); + + let i = 0; + + for (let x = 0; x < MAP_CHUNK_FACE_COUNT_X + 1; x++) { + // Evens + for (let y = 0; y < MAP_CHUNK_FACE_COUNT_Y + 1; y++) { + vertexBufferView.setFloat32(i * 16 + 0, vxe[x], true); + vertexBufferView.setFloat32(i * 16 + 4, vye[y], true); + + i++; + } + + // Odds + if (x < MAP_CHUNK_FACE_COUNT_X) { + for (let y = 0; y < MAP_CHUNK_FACE_COUNT_Y; y++) { + vertexBufferView.setFloat32(i * 16 + 0, vxo[x], true); + vertexBufferView.setFloat32(i * 16 + 4, vyo[y], true); + + i++; + } + } + } + + return vertexBuffer; +}; + +const createTerrainVertexBuffer = (vertexHeights: Float32Array, vertexNormals: Int8Array) => { + // Copy the default vertex buffer (contains x and y coordinates) + const data = DEFAULT_TERRAIN_VERTEX_BUFFER.slice(0); + const view = new DataView(data); + + for (let i = 0; i < vertexHeights.length; i++) { + const vertexOfs = i * 16; + + view.setFloat32(vertexOfs + 8, vertexHeights[i], true); + + const normalOfs = i * 3; + view.setInt8(vertexOfs + 12, vertexNormals[normalOfs + 0]); + view.setInt8(vertexOfs + 13, vertexNormals[normalOfs + 1]); + view.setInt8(vertexOfs + 14, vertexNormals[normalOfs + 2]); + } + + return data; +}; + +const createTerrainIndexBuffer = (holes: number) => { + const data = new ArrayBuffer(MAP_CHUNK_FACE_COUNT_X * MAP_CHUNK_FACE_COUNT_Y * 3 * 4 * 2); + const view = new DataView(data); + + let i = 0; + + for (let x = 0; x < MAP_CHUNK_FACE_COUNT_X; x++) { + for (let y = 0; y < MAP_CHUNK_FACE_COUNT_Y; y++) { + if (isTerrainHole(holes, x, y)) { + continue; + } + + const f = x * 17 + y + 9; + + view.setUint16(i * 24 + 0, f, true); + view.setUint16(i * 24 + 2, f - 9, true); + view.setUint16(i * 24 + 4, f + 8, true); + + view.setUint16(i * 24 + 6, f, true); + view.setUint16(i * 24 + 8, f - 8, true); + view.setUint16(i * 24 + 10, f - 9, true); + + view.setUint16(i * 24 + 12, f, true); + view.setUint16(i * 24 + 14, f + 9, true); + view.setUint16(i * 24 + 16, f - 8, true); + + view.setUint16(i * 24 + 18, f, true); + view.setUint16(i * 24 + 20, f + 8, true); + view.setUint16(i * 24 + 22, f + 9, true); + + i++; + } + } + + return data; +}; + +const mergeTerrainLayerSplats = (layerSplats: Uint8Array[], width: number, height: number) => { + const data = new Uint8Array(width * height * 4); + + // Treat each layer splat as a separate color channel + for (let l = 0; l < layerSplats.length; l++) { + const layerSplat = layerSplats[l]; + + for (let i = 0; i < width * height; i++) { + data[i * 4 + l] = layerSplat[i]; + } + } + + return data; +}; + +export { + createDefaultTerrainVertexBuffer, + createTerrainIndexBuffer, + createTerrainVertexBuffer, + mergeTerrainLayerSplats, +}; diff --git a/src/lib/map/loader/worker.ts b/src/lib/map/loader/worker.ts new file mode 100644 index 0000000..8799b0c --- /dev/null +++ b/src/lib/map/loader/worker.ts @@ -0,0 +1,3 @@ +import MapLoaderWorker from './MapLoaderWorker.js'; + +const worker = new MapLoaderWorker(); diff --git a/src/lib/terrain/TerrainManager.ts b/src/lib/map/terrain/TerrainManager.ts similarity index 51% rename from src/lib/terrain/TerrainManager.ts rename to src/lib/map/terrain/TerrainManager.ts index 9dbc639..0288459 100644 --- a/src/lib/terrain/TerrainManager.ts +++ b/src/lib/map/terrain/TerrainManager.ts @@ -1,11 +1,11 @@ import * as THREE from 'three'; -import { MapChunk, MapArea } from '@wowserhq/format'; -import { createSplatTexture } from './material.js'; -import { createTerrainIndexBuffer, createTerrainVertexBuffer } from './geometry.js'; -import TextureManager from '../texture/TextureManager.js'; +import TextureManager from '../../texture/TextureManager.js'; import TerrainMaterial from './TerrainMaterial.js'; import TerrainMesh from './TerrainMesh.js'; -import { AssetHost } from '../asset.js'; +import { AssetHost } from '../../asset.js'; +import { MapAreaSpec, TerrainSpec } from '../loader/types.js'; + +const SPLAT_TEXTURE_PLACEHOLDER = new THREE.Texture(); type TerrainManagerOptions = { host: AssetHost; @@ -21,7 +21,7 @@ class TerrainManager { this.#textureManager = options.textureManager ?? new TextureManager({ host: options.host }); } - getArea(areaId: number, area: MapArea): Promise { + getArea(areaId: number, area: MapAreaSpec): Promise { const loaded = this.#loadedAreas.get(areaId); if (loaded) { return Promise.resolve(loaded); @@ -51,14 +51,13 @@ class TerrainManager { this.#loadedAreas.delete(areaId); } - async #loadArea(areaId: number, area: MapArea) { + async #loadArea(areaId: number, area: MapAreaSpec) { const group = new THREE.Group(); group.name = 'terrain'; group.matrixAutoUpdate = false; group.matrixWorldAutoUpdate = false; - const renderableChunks = area.chunks.filter((chunk) => chunk.layers.length > 0); - const meshes = await Promise.all(renderableChunks.map((chunk) => this.#createMesh(chunk))); + const meshes = await Promise.all(area.terrain.map((terrain) => this.#createMesh(terrain))); for (const mesh of meshes) { group.add(mesh); @@ -70,20 +69,16 @@ class TerrainManager { return group; } - async #createMesh(chunk: MapChunk) { - const [geometry, material] = await Promise.all([ - this.#createGeometry(chunk), - this.#createMaterial(chunk), - ]); + async #createMesh(spec: TerrainSpec) { + const geometry = this.#createGeometry(spec); + const material = await this.#createMaterial(spec); - return new TerrainMesh(chunk.position, geometry, material); + return new TerrainMesh(spec.position, geometry, material); } - async #createGeometry(chunk: MapChunk) { - const [vertexBuffer, indexBuffer] = await Promise.all([ - createTerrainVertexBuffer(chunk), - createTerrainIndexBuffer(chunk), - ]); + #createGeometry(spec: TerrainSpec) { + const vertexBuffer = spec.geometry.vertexBuffer; + const indexBuffer = spec.geometry.indexBuffer; const geometry = new THREE.BufferGeometry(); @@ -99,13 +94,44 @@ class TerrainManager { return geometry; } - async #createMaterial(chunk: MapChunk) { - const [splatTexture, ...layerTextures] = await Promise.all([ - createSplatTexture(chunk.layers), - ...chunk.layers.map((layer) => this.#textureManager.get(layer.texture)), - ]); + async #createMaterial(spec: TerrainSpec) { + const splatTexture = this.#createSplatTexture(spec); + const layerTextures = await Promise.all( + spec.material.layers.map((layer) => this.#textureManager.get(layer.texturePath)), + ); + + return new TerrainMaterial(spec.material.layers.length, layerTextures, splatTexture); + } + + #createSplatTexture(spec: TerrainSpec) { + const splat = spec.material.splat; + + // No splat (0 or 1 layer) + + if (!splat) { + // Return placeholder texture to keep uniforms consistent + return SPLAT_TEXTURE_PLACEHOLDER; + } + + // Single splat (2 layers) + + if (splat.channels === 1) { + const texture = new THREE.DataTexture(splat.data, splat.width, splat.height, THREE.RedFormat); + texture.minFilter = texture.magFilter = THREE.LinearFilter; + texture.anisotropy = 16; + texture.needsUpdate = true; + + return texture; + } + + // Multiple splats (3+ layers) + + const texture = new THREE.DataTexture(splat.data, splat.width, splat.height, THREE.RGBAFormat); + texture.minFilter = texture.magFilter = THREE.LinearFilter; + texture.anisotropy = 16; + texture.needsUpdate = true; - return new TerrainMaterial(chunk.layers.length, layerTextures, splatTexture); + return texture; } } diff --git a/src/lib/terrain/TerrainMaterial.ts b/src/lib/map/terrain/TerrainMaterial.ts similarity index 100% rename from src/lib/terrain/TerrainMaterial.ts rename to src/lib/map/terrain/TerrainMaterial.ts diff --git a/src/lib/terrain/TerrainMesh.ts b/src/lib/map/terrain/TerrainMesh.ts similarity index 100% rename from src/lib/terrain/TerrainMesh.ts rename to src/lib/map/terrain/TerrainMesh.ts diff --git a/src/lib/terrain/shaders.ts b/src/lib/map/terrain/shaders.ts similarity index 100% rename from src/lib/terrain/shaders.ts rename to src/lib/map/terrain/shaders.ts diff --git a/src/lib/terrain/geometry.ts b/src/lib/terrain/geometry.ts deleted file mode 100644 index 306b20a..0000000 --- a/src/lib/terrain/geometry.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { MapChunk, MAP_CHUNK_FACE_COUNT_X, MAP_CHUNK_FACE_COUNT_Y } from '@wowserhq/format'; -import terrainWorker from './worker.js'; - -const createTerrainVertexBuffer = (chunk: MapChunk) => - terrainWorker.call('createTerrainVertexBuffer', chunk.vertexHeights, chunk.vertexNormals); - -const createTerrainIndexBuffer = (chunk: MapChunk) => - terrainWorker.call( - 'createTerrainIndexBuffer', - chunk.holes, - MAP_CHUNK_FACE_COUNT_X, - MAP_CHUNK_FACE_COUNT_Y, - ); - -export { createTerrainVertexBuffer, createTerrainIndexBuffer }; diff --git a/src/lib/terrain/material.ts b/src/lib/terrain/material.ts deleted file mode 100644 index b580f0a..0000000 --- a/src/lib/terrain/material.ts +++ /dev/null @@ -1,54 +0,0 @@ -import * as THREE from 'three'; -import { MAP_LAYER_SPLAT_X, MAP_LAYER_SPLAT_Y, MapLayer } from '@wowserhq/format'; -import terrainWorker from './worker.js'; - -const SPLAT_TEXTURE_PLACEHOLDER = new THREE.Texture(); - -const createSplatTexture = async (layers: MapLayer[]) => { - // Handle no splat - - if (layers.length <= 1) { - // Return placeholder texture to keep uniforms consistent - return SPLAT_TEXTURE_PLACEHOLDER; - } - - // Handle single splat (2 layers) - - if (layers.length === 2) { - const texture = new THREE.DataTexture( - layers[1].splat, - MAP_LAYER_SPLAT_X, - MAP_LAYER_SPLAT_Y, - THREE.RedFormat, - ); - texture.minFilter = texture.magFilter = THREE.LinearFilter; - texture.anisotropy = 16; - texture.needsUpdate = true; - - return texture; - } - - // Handle multiple splats (3+ layers) - - const layerSplats = layers.slice(1).map((layer) => layer.splat); - const data = await terrainWorker.call( - 'mergeLayerSplats', - layerSplats, - MAP_LAYER_SPLAT_X, - MAP_LAYER_SPLAT_Y, - ); - - const texture = new THREE.DataTexture( - data, - MAP_LAYER_SPLAT_X, - MAP_LAYER_SPLAT_Y, - THREE.RGBAFormat, - ); - texture.minFilter = texture.magFilter = THREE.LinearFilter; - texture.anisotropy = 16; - texture.needsUpdate = true; - - return texture; -}; - -export { createSplatTexture }; diff --git a/src/lib/terrain/worker.ts b/src/lib/terrain/worker.ts deleted file mode 100644 index 8667078..0000000 --- a/src/lib/terrain/worker.ts +++ /dev/null @@ -1,7 +0,0 @@ -import context from './worker/context.js'; -import constants from './worker/constants.js'; -import SceneWorker from '../SceneWorker.js'; - -const terrainWorker = new SceneWorker('terrain-worker', context, constants); - -export default terrainWorker; diff --git a/src/lib/terrain/worker/context.ts b/src/lib/terrain/worker/context.ts deleted file mode 100644 index f56a079..0000000 --- a/src/lib/terrain/worker/context.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as geometry from './geometry.js'; -import * as material from './material.js'; - -const context = { - ...geometry, - ...material, -}; - -export default context; diff --git a/src/lib/terrain/worker/geometry.ts b/src/lib/terrain/worker/geometry.ts deleted file mode 100644 index 4b46c49..0000000 --- a/src/lib/terrain/worker/geometry.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - MAP_CHUNK_WIDTH, - MAP_CHUNK_HEIGHT, - MAP_CHUNK_FACE_COUNT_X, - MAP_CHUNK_FACE_COUNT_Y, - MAP_CHUNK_VERTEX_COUNT, - MAP_CHUNK_VERTEX_STEP_X, - MAP_CHUNK_VERTEX_STEP_Y, -} from '@wowserhq/format'; - -const isTerrainHole = (holes: number, x: number, y: number) => { - const column = (y / 2) | 0; - const row = (x / 2) | 0; - const hole = 1 << (column * 4 + row); - - return (hole & holes) !== 0; -}; - -const createTerrainVertexBuffer = (vertexHeights: Float32Array, vertexNormals: Int8Array) => { - // Copy the default vertex buffer (contains x and y coordinates) - const data = self['DEFAULT_TERRAIN_VERTEX_BUFFER'].slice(0); - const view = new DataView(data); - - for (let i = 0; i < vertexHeights.length; i++) { - const vertexOfs = i * 16; - - view.setFloat32(vertexOfs + 8, vertexHeights[i], true); - - const normalOfs = i * 3; - view.setInt8(vertexOfs + 12, vertexNormals[normalOfs + 0]); - view.setInt8(vertexOfs + 13, vertexNormals[normalOfs + 1]); - view.setInt8(vertexOfs + 14, vertexNormals[normalOfs + 2]); - } - - return data; -}; - -const createTerrainIndexBuffer = (holes: number, faceCountX: number, faceCountY: number) => { - const data = new ArrayBuffer(faceCountX * faceCountY * 3 * 4 * 2); - const view = new DataView(data); - - let i = 0; - - for (let x = 0; x < faceCountX; x++) { - for (let y = 0; y < faceCountY; y++) { - if (isTerrainHole(holes, x, y)) { - continue; - } - - const f = x * 17 + y + 9; - - view.setUint16(i * 24 + 0, f, true); - view.setUint16(i * 24 + 2, f - 9, true); - view.setUint16(i * 24 + 4, f + 8, true); - - view.setUint16(i * 24 + 6, f, true); - view.setUint16(i * 24 + 8, f - 8, true); - view.setUint16(i * 24 + 10, f - 9, true); - - view.setUint16(i * 24 + 12, f, true); - view.setUint16(i * 24 + 14, f + 9, true); - view.setUint16(i * 24 + 16, f - 8, true); - - view.setUint16(i * 24 + 18, f, true); - view.setUint16(i * 24 + 20, f + 8, true); - view.setUint16(i * 24 + 22, f + 9, true); - - i++; - } - } - - return data; -}; - -export { - MAP_CHUNK_WIDTH, - MAP_CHUNK_HEIGHT, - MAP_CHUNK_FACE_COUNT_X, - MAP_CHUNK_FACE_COUNT_Y, - MAP_CHUNK_VERTEX_COUNT, - MAP_CHUNK_VERTEX_STEP_X, - MAP_CHUNK_VERTEX_STEP_Y, - isTerrainHole, - createTerrainVertexBuffer, - createTerrainIndexBuffer, -}; diff --git a/src/lib/terrain/worker/material.ts b/src/lib/terrain/worker/material.ts deleted file mode 100644 index 1a1d06d..0000000 --- a/src/lib/terrain/worker/material.ts +++ /dev/null @@ -1,16 +0,0 @@ -const mergeLayerSplats = (layerSplats: Uint8Array[], width: number, height: number) => { - const data = new Uint8Array(width * height * 4); - - // Treat each layer splat as a separate color channel - for (let l = 0; l < layerSplats.length; l++) { - const layerSplat = layerSplats[l]; - - for (let i = 0; i < width * height; i++) { - data[i * 4 + l] = layerSplat[i]; - } - } - - return data; -}; - -export { mergeLayerSplats };