Ok, so far the terrain looks pretty good, but I can't walk on it. How the heck do I do that?
Two options as I see it...
- Try and use the heightmap to figure out what the height is at my current position and just get the camera to follow that.
- Generate collision geometry from the heightmap and then do ray-triangle based collision detection.
Yeah yeah, I'm sure I could just find some free resources/JS library on t'interweb that will do all this gubbins for me and give me a full blown phsyics engine to boot... but where's the fun in that?! Oh yeah, by the way, did I mention I'm nuts.
After much thought and general mucking around I opted for the 2nd option. To create a geometry object that would contain all the vertices/faces for the terrain. I don't need to render it so no need to create a mesh (can you imagine the insanity of rendering a mesh of this scale in the browser on a low-end device... madness I tell you, madness!). I could potentially, at a future date, even stream the bit of collision geometry that I need from the Node back-end on the fly to speed things up further.
For now though I'll just create the geometry in memory on the client-side and use that. It's just an array of vertices after all.
this.generateCollisionMesh = function () {
// Render the height map texture off-screen and read the pixel data into an array
let texSize = _this.heightMap.image.width;
_this.pickingScene = new THREE.Scene();
_this.renderTarget = new THREE.WebGLRenderTarget( texSize, texSize );
_this.pickingCamera = new THREE.OrthographicCamera( 0, texSize, 0, texSize, 0.1, 10 );
_this.renderTarget.texture.minFilter = THREE.NearestFilter;
_this.renderTarget.texture.magFilter = THREE.NearestFilter;
let mapGeom = new THREE.PlaneBufferGeometry( texSize, texSize, 1, 1 );
let mapMaterial = new THREE.MeshBasicMaterial( {
color: 0xffffff,
side: THREE.DoubleSide
} );
mapMaterial.map = _this.heightMap;
let mapMesh = new THREE.Mesh( mapGeom, mapMaterial );
mapMesh.position.set( texSize / 2, texSize / 2, -1 );
mapMesh.rotation.x = Math.PI;
_this.pickingScene.add( mapMesh );
let pixelData = new Uint8Array( 4 * Math.pow( texSize, 2 ) );
_this.renderer.render( _this.pickingScene, _this.pickingCamera, _this.renderTarget );
let ctx = _this.renderer.getContext();
ctx.readPixels( 0, 0, texSize, texSize, ctx.RGBA, ctx.UNSIGNED_BYTE, pixelData );
// Create a plane geometry that we can modify to create the collision mesh
let tSubdivisions = ( _this.mapSize / _this.viewSize ) * _this.subdivisions;
_this.collisionGeom = new THREE.PlaneGeometry( _this.mapSize, _this.mapSize, tSubdivisions, tSubdivisions );
let mapHalfSize = _this.mapSize / 2;
for ( let i = 0; i < _this.collisionGeom.vertices.length; i++ ) {
let v = _this.collisionGeom.vertices[i];
let tx = ( ( ( v.x + mapHalfSize ) / _this.mapSize ) * texSize );
let ty = ( ( ( v.y + mapHalfSize ) / _this.mapSize ) * texSize );
let pdIdx = Math.clamp( Math.floor( tx + ( ty * texSize ) ) * 4, 0, pixelData.length );
if ( isNaN( pdIdx ) || isNaN( pixelData[pdIdx] ) ) {
continue;
}
v.z = ( pixelData[pdIdx] / 255 ) * _this.maxHeight;
}
_this.collisionGeom.verticesNeedUpdate = true;
_this.collisionGeom.normalsNeedUpdate = true;
_this.collisionGeom.computeVertexNormals();
_this.collisionGeom.computeFaceNormals();
_this.collisionGeom.computeBoundingBox();
}
I don't even know if I need those 5 lines at the end but I'll leave them in for good measure.
p.s. It took me a few days to get this right as the collision geometry just wasn't quite matching up to the actual rendered mesh. Turns out it was because I'd not set:
_this.renderTarget.texture.minFilter = THREE.NearestFilter;
_this.renderTarget.texture.magFilter = THREE.NearestFilter;
Oh my wasted life!!! ?
Terrain collision geometry now working so I created a little function to test the height at a given point...
this.heightAt = function ( x, y ) {
function rayTriangleIntersection( p, ray, v1, v2, v3 ) {
let ab = new THREE.Vector3().subVectors( v2, v1 );
let ac = new THREE.Vector3().subVectors( v3, v1 );
let n = new THREE.Vector3().crossVectors( ab, ac );
let d = ray.dot( n );
if ( d <= 0 ) return false;
let ap = new THREE.Vector3().subVectors( p, v1 );
let t = -ap.dot( n );
if ( t < 0 ) return false;
let e = new THREE.Vector3().crossVectors( ray, ap );
let u, v, w;
v = ac.dot( e );
if ( v < 0 || v > d ) return false;
w = -ab.dot( e );
if ( w < 0 || v + w > d ) return false;
let ood = 1.0 / d;
t *= ood;
v *= ood;
w *= ood;
u = 1.0 - v - w;
let pRay = ray.multiplyScalar( t );
return {
point: pRay.add( p ),
normal: new THREE.Vector3( u, v, w )
};
}
let halfSize = _this.collisionGeom.boundingBox.max.x;
let mapSubdiv = _this.mapSize / _this.viewSize * _this.subdivisions;
let localPos = new THREE.Vector3( x, y, 0 );
localPos.add( new THREE.Vector3( halfSize, halfSize, 0 ) );
let t = localPos.divideScalar( halfSize * 2 );
t.multiplyScalar( mapSubdiv );
// Determine a 'grid' position from the local position
let g = new THREE.Vector2( Math.floor( t.x ), Math.floor( t.y ) );
// And determine which half of the grid square the co-ords are in
let faceOffset = ( 1 - Math.frac( t.y ) <= Math.frac( t.x ) ) ? 1 : 0;
// Use this to calculate the face array index.
let idx = ( g.x + ( g.y * mapSubdiv ) ) * 2 + faceOffset;
let face = _this.collisionGeom.faces[idx];
if ( !face )
return;
// Then use the associated vertices to calc the intersection
let v1 = _this.collisionGeom.vertices[face.a];
let v2 = _this.collisionGeom.vertices[face.b];
let v3 = _this.collisionGeom.vertices[face.c];
let p = rayTriangleIntersection( new THREE.Vector3( x, -y, 0 ), new THREE.Vector3( 0, 0, 1 ), v1, v2, v3 );
return p === false ? p : { h: p.point.z, normal: face.normal };
}
I'm sure I could code that better.... but really, I can't be bothered.... it works....