Skip to content

Commit

Permalink
feat(terrain): add TerrainManager
Browse files Browse the repository at this point in the history
  • Loading branch information
fallenoak committed Dec 28, 2023
1 parent dbc7b18 commit a75eba2
Show file tree
Hide file tree
Showing 11 changed files with 486 additions and 0 deletions.
3 changes: 3 additions & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ export * from './AssetManager.js';
export * from './FormatManager.js';
export * from './TextureManager.js';
export * from './SceneWorker.js';
export * from './terrain/TerrainManager.js';
export * from './terrain/TerrainMesh.js';
export * from './terrain/TerrainMaterial.js';
90 changes: 90 additions & 0 deletions src/lib/terrain/TerrainManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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 '../TextureManager.js';
import TerrainMaterial from './TerrainMaterial.js';
import TerrainMesh from './TerrainMesh.js';

class TerrainManager {
#textureManager: TextureManager;
#loadedAreas = new globalThis.Map<number, THREE.Group>();
#loadingAreas = new globalThis.Map<number, Promise<THREE.Group>>();

constructor(textureManager: TextureManager) {
this.#textureManager = textureManager;
}

getArea(areaId: number, area: MapArea): Promise<THREE.Group> {
const loaded = this.#loadedAreas.get(areaId);
if (loaded) {
return Promise.resolve(loaded);
}

const alreadyLoading = this.#loadingAreas.get(areaId);
if (alreadyLoading) {
return alreadyLoading;
}

const loading = this.#loadArea(areaId, area);
this.#loadingAreas.set(areaId, loading);

return loading;
}

async #loadArea(areaId: number, area: MapArea) {
const group = new THREE.Group();
group.name = 'terrain';

for (const chunk of area.chunks) {
const mesh = await this.#createMesh(chunk);
group.add(mesh);
}

this.#loadedAreas.set(areaId, group);
this.#loadingAreas.delete(areaId);

return group;
}

async #createMesh(chunk: MapChunk) {
const [geometry, material] = await Promise.all([
this.#createGeometry(chunk),
this.#createMaterial(chunk),
]);

return new TerrainMesh(chunk.position, geometry, material);
}

async #createGeometry(chunk: MapChunk) {
const [vertexBuffer, indexBuffer] = await Promise.all([
createTerrainVertexBuffer(chunk),
createTerrainIndexBuffer(chunk),
]);

const geometry = new THREE.BufferGeometry();

const positions = new THREE.InterleavedBuffer(new Float32Array(vertexBuffer), 4);
geometry.setAttribute('position', new THREE.InterleavedBufferAttribute(positions, 3, 0, false));

const normals = new THREE.InterleavedBuffer(new Int8Array(vertexBuffer), 16);
geometry.setAttribute('normal', new THREE.InterleavedBufferAttribute(normals, 4, 12, true));

const index = new THREE.BufferAttribute(new Uint16Array(indexBuffer), 1, false);
geometry.setIndex(index);

return geometry;
}

async #createMaterial(chunk: MapChunk) {
const [splatTexture, ...layerTextures] = await Promise.all([
createSplatTexture(chunk.layers),
...chunk.layers.map((layer) => this.#textureManager.get(layer.texture)),
]);

return new TerrainMaterial(chunk.layers.length, layerTextures, splatTexture);
}
}

export default TerrainManager;
export { TerrainManager };
25 changes: 25 additions & 0 deletions src/lib/terrain/TerrainMaterial.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as THREE from 'three';
import { fragmentShader, vertexShader } from './shaders.js';

class TerrainMaterial extends THREE.RawShaderMaterial {
constructor(layerCount: number, layerTextures: THREE.Texture[], splatTexture: THREE.Texture) {
super();

this.uniforms = {
layerCount: { value: layerCount },
layers: { value: layerTextures },
splat: { value: splatTexture },
fogColor: { value: new THREE.Color(0.25, 0.5, 0.8) },
fogParams: { value: new THREE.Vector3(0.0, 1066.0, 1.0) },
};

this.vertexShader = vertexShader;
this.fragmentShader = fragmentShader;
this.glslVersion = THREE.GLSL3;
this.side = THREE.FrontSide;
this.blending = 0;
}
}

export default TerrainMaterial;
export { TerrainMaterial };
18 changes: 18 additions & 0 deletions src/lib/terrain/TerrainMesh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as THREE from 'three';

class TerrainMesh extends THREE.Mesh {
constructor(position: Float32Array, geometry: THREE.BufferGeometry, material: THREE.Material) {
// We need these for frustum culling, so we might as well take the hit on creation
geometry.computeBoundingSphere();

super(geometry);

this.material = material;

this.position.set(position[0], position[1], position[2]);
this.updateMatrixWorld();
}
}

export default TerrainMesh;
export { TerrainMesh };
15 changes: 15 additions & 0 deletions src/lib/terrain/geometry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { MAP_CHUNK_FACE_COUNT_X, MAP_CHUNK_FACE_COUNT_Y, MapChunk } 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 };
54 changes: 54 additions & 0 deletions src/lib/terrain/material.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
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 };
108 changes: 108 additions & 0 deletions src/lib/terrain/shaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
const vertexShader = `
precision highp float;
uniform mat4 modelMatrix;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform vec3 cameraPosition;
in vec3 position;
in vec3 normal;
out vec2 layerCoord;
out vec2 splatCoord;
out float light;
out float cameraDistance;
void main() {
// Terrain tileset textures repeat 8 times over the terrain chunk
vec4 layerScale = vec4(-0.24, -0.24, 0.0, 0.0);
layerCoord.xy = position.xy * layerScale.xy;
// Splat textures do not repeat over the terrain chunk
vec4 splatScale = vec4(-0.03, -0.03, 0.0, 0.0);
splatCoord.yx = position.xy * splatScale.xy;
// TODO - Replace with lighting manager controlled value
vec3 lightDirection = vec3(-1, -1, -1);
light = dot(normal, -normalize(lightDirection));
// Calculate camera distance for fog coloring in fragment shader
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
cameraDistance = distance(cameraPosition, worldPosition.xyz);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;

const fragmentShader = `
precision highp float;
uniform int layerCount;
uniform sampler2D layers[4];
uniform sampler2D splat;
uniform vec3 fogColor;
uniform vec3 fogParams;
in vec2 layerCoord;
in vec2 splatCoord;
in float light;
in float cameraDistance;
out vec4 color;
vec4 applyFog(vec4 color) {
float fogStart = fogParams.x;
float fogEnd = fogParams.y;
float fogModifier = fogParams.z;
float fogFactor = (fogEnd - cameraDistance) / (fogEnd - fogStart);
fogFactor = clamp(fogFactor * fogModifier, 0.0, 1.0);
vec4 mixed = vec4(mix(color.rgb, fogColor.rgb, 1.0 - fogFactor), color.a);
return mixed;
}
vec4 blendLayer(vec4 color, vec4 layer, vec4 blend) {
return (layer * blend) + ((1.0 - blend) * color);
}
void main() {
vec4 layer;
vec4 blend;
// 1st layer
color = texture(layers[0], layerCoord);
blend = texture(splat, splatCoord);
// 2nd layer
if (layerCount > 1) {
layer = texture(layers[1], layerCoord);
color = blendLayer(color, layer, blend.rrrr);
}
if (layerCount > 2) {
layer = texture(layers[2], layerCoord);
color = blendLayer(color, layer, blend.gggg);
}
// 3rd layer
if (layerCount > 3) {
layer = texture(layers[3], layerCoord);
color = blendLayer(color, layer, blend.bbbb);
}
// Fixed lighting
vec3 lightDiffuse = normalize(vec3(0.25, 0.5, 1.0));
vec3 lightAmbient = normalize(vec3(0.5, 0.5, 0.5));
color.rgb *= lightDiffuse * light + lightAmbient;
color.rgb = applyFog(color).rgb;
// Terrain is always opaque
color.a = 1.0;
}
`;

export { vertexShader, fragmentShader };
6 changes: 6 additions & 0 deletions src/lib/terrain/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import context from './worker/context.js';
import SceneWorker from '../SceneWorker.js';

const terrainWorker = new SceneWorker('terrain-worker', context);

export default terrainWorker;
9 changes: 9 additions & 0 deletions src/lib/terrain/worker/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as geometry from './geometry.js';
import * as material from './material.js';

const context = {
...geometry,
...material,
};

export default context;
Loading

0 comments on commit a75eba2

Please sign in to comment.