├── src ├── main.ts ├── types │ └── three-global.d.ts ├── tsconfig.json ├── skydome.ts ├── util.ts ├── notification.ts ├── logger.ts ├── fps.ts ├── gmath.ts ├── browser.ts ├── bufferset.ts ├── fullscreen.ts ├── anim.ts ├── input.ts ├── tslint.json ├── water.ts ├── loader.ts ├── terramap.ts ├── vec.ts ├── simplex.ts ├── terrain.ts ├── app.ts ├── grass.ts ├── player.ts ├── heightfield.ts └── world.ts ├── .gitignore ├── public ├── data │ ├── grass.jpg │ ├── noise.jpg │ ├── skydome.jpg │ ├── skyenv.jpg │ ├── heightmap.jpg │ ├── terrain1.jpg │ └── terrain2.jpg ├── img │ ├── share.jpg │ ├── share.0.jpg │ ├── options.svg │ └── fullscreen.svg ├── audio │ └── chopin-nocturne-in-d-flat-major-op-27-no-2.mp3 ├── shader │ ├── water.vert.glsl │ ├── terrain.vert.glsl │ ├── grass.frag.glsl │ ├── water.frag.glsl │ ├── terrain.frag.glsl │ └── grass.vert.glsl ├── css │ └── style.css ├── index.html └── js │ └── terra.js ├── screenshots ├── intro.jpg ├── too-specular.jpg ├── dirt-transition.jpg ├── heightmap-diagram.jpg └── terrain-transition.jpg ├── .vscode └── settings.json ├── license.txt ├── package.json └── readme.md /src/main.ts: -------------------------------------------------------------------------------- 1 | import App from './app' 2 | 3 | App().run() 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | creative 3 | npm-debug.log 4 | *.db 5 | -------------------------------------------------------------------------------- /public/data/grass.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/public/data/grass.jpg -------------------------------------------------------------------------------- /public/data/noise.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/public/data/noise.jpg -------------------------------------------------------------------------------- /public/img/share.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/public/img/share.jpg -------------------------------------------------------------------------------- /screenshots/intro.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/screenshots/intro.jpg -------------------------------------------------------------------------------- /public/data/skydome.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/public/data/skydome.jpg -------------------------------------------------------------------------------- /public/data/skyenv.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/public/data/skyenv.jpg -------------------------------------------------------------------------------- /public/img/share.0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/public/img/share.0.jpg -------------------------------------------------------------------------------- /public/data/heightmap.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/public/data/heightmap.jpg -------------------------------------------------------------------------------- /public/data/terrain1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/public/data/terrain1.jpg -------------------------------------------------------------------------------- /public/data/terrain2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/public/data/terrain2.jpg -------------------------------------------------------------------------------- /screenshots/too-specular.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/screenshots/too-specular.jpg -------------------------------------------------------------------------------- /screenshots/dirt-transition.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/screenshots/dirt-transition.jpg -------------------------------------------------------------------------------- /screenshots/heightmap-diagram.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/screenshots/heightmap-diagram.jpg -------------------------------------------------------------------------------- /screenshots/terrain-transition.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/screenshots/terrain-transition.jpg -------------------------------------------------------------------------------- /src/types/three-global.d.ts: -------------------------------------------------------------------------------- 1 | import _THREE from 'three' 2 | 3 | declare global { 4 | const THREE: typeof _THREE; 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": false, 3 | "files.trimTrailingWhitespace": true, 4 | "typescript.tsdk": "node_modules/typescript/lib" 5 | } 6 | -------------------------------------------------------------------------------- /public/audio/chopin-nocturne-in-d-flat-major-op-27-no-2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacejack/terra/HEAD/public/audio/chopin-nocturne-in-d-flat-major-op-27-no-2.mp3 -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | This work is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License: 2 | 3 | http://creativecommons.org/licenses/by-nc/4.0/ 4 | 5 | Individual sources where indicated are licensed MIT 6 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": ["es2016", "dom"], 6 | "strict": true, 7 | "esModuleInterop": true 8 | }, 9 | "files": ["main.ts"], 10 | "compileOnSave": false, 11 | "buildOnSave": false 12 | } 13 | -------------------------------------------------------------------------------- /public/shader/water.vert.glsl: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2017 by Mike Linkovich 3 | 4 | precision highp float; 5 | 6 | uniform mat4 modelViewMatrix; 7 | uniform mat4 projectionMatrix; 8 | 9 | attribute vec3 position; 10 | 11 | varying vec3 vSurfacePos; 12 | 13 | void main() { 14 | vSurfacePos = position; 15 | gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); 16 | } 17 | -------------------------------------------------------------------------------- /src/skydome.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | export function createMesh ( 4 | tex: THREE.Texture, radius: number, lats = 16, lngs = 32 5 | ) { 6 | tex.wrapS = tex.wrapT = THREE.RepeatWrapping 7 | return new THREE.Mesh( 8 | new THREE.SphereGeometry( 9 | radius, lngs, lats, 0, Math.PI * 2.0, 0, Math.PI / 2.0 10 | ).rotateX(Math.PI / 2.0).rotateZ(Math.PI), 11 | 12 | new THREE.MeshBasicMaterial({ 13 | color: 0xFFFFFF, side: THREE.BackSide, map: tex, fog: false 14 | }) 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | export function $e (id: string) { 5 | return document.getElementById(id) as HTMLElement 6 | } 7 | 8 | export function $i (id: string) { 9 | return document.getElementById(id) as HTMLInputElement 10 | } 11 | 12 | export function detectWebGL() { 13 | try { 14 | const canvas = document.createElement('canvas') 15 | return ( 16 | !!canvas.getContext('webgl') || !!canvas.getContext('experimental-webgl') 17 | ) 18 | } 19 | catch (e) { 20 | return null 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/notification.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | import {$e} from './util' 5 | import * as anim from './anim' 6 | 7 | let notifying = false 8 | 9 | export default function notify(msg: string) { 10 | const elTxt = $e('notification_text') 11 | elTxt.textContent = msg 12 | if (notifying) return 13 | const el = $e('notification') 14 | el.style.display = 'block' 15 | el.style.opacity = '1.0' 16 | notifying = true 17 | setTimeout(function() { 18 | anim.fadeOut(el, 1000, function() { 19 | el.style.display = 'none' 20 | elTxt.textContent = '' 21 | notifying = false 22 | }) 23 | }, 4000) 24 | } 25 | -------------------------------------------------------------------------------- /public/shader/terrain.vert.glsl: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2017 by Mike Linkovich 3 | 4 | precision highp float; 5 | 6 | uniform mat4 modelViewMatrix; 7 | uniform mat4 projectionMatrix; 8 | uniform vec2 offset; 9 | uniform vec2 uvOffset; 10 | uniform sampler2D heightMap; 11 | uniform vec3 heightMapScale; 12 | 13 | attribute vec3 position; 14 | attribute vec2 uv; 15 | 16 | varying vec2 vUv; 17 | varying vec2 vSamplePos; 18 | 19 | void main() { 20 | vec2 pos = vec2(position.xy + offset); 21 | vSamplePos = pos * heightMapScale.xy + vec2(0.5, 0.5); 22 | vec4 ch = texture2D(heightMap, vSamplePos); 23 | float height = ch.r * heightMapScale.z; 24 | vUv = uv + uvOffset; 25 | gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, height, 1.0); 26 | } 27 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | import {$e} from './util' 5 | 6 | let visible = false 7 | 8 | export function setText (txt: string) { 9 | $e('logger').textContent = txt 10 | } 11 | 12 | export function setHtml (html: string) { 13 | $e('logger').innerHTML = html 14 | } 15 | 16 | export function toggle() { 17 | const el = $e('logger') 18 | visible = !visible 19 | if (visible) { 20 | el.style.display = 'inline-block' 21 | } else { 22 | el.style.display = 'none' 23 | } 24 | } 25 | 26 | export function hide() { 27 | $e('logger').style.display = 'none' 28 | visible = false 29 | } 30 | 31 | export function show() { 32 | $e('logger').style.display = 'inline-block' 33 | visible = true 34 | } 35 | 36 | export function isVisible() { 37 | return visible 38 | } 39 | -------------------------------------------------------------------------------- /src/fps.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | /** 5 | * Create instance of Frames Per Second Monitor 6 | */ 7 | export default function FPSMonitor(num = 16) { 8 | const ticks = new Array(num) 9 | let sum = 0 10 | let index = 0 11 | let f = 60.0 // frames per sec initial assumption 12 | for (let i = 0; i < num; ++i) { 13 | ticks[i] = 16.66666667 14 | sum += 16.66666667 15 | } 16 | 17 | /** 18 | * Update with new sample 19 | * @return New average frames/second 20 | */ 21 | function update (dt: number) { 22 | sum -= ticks[index] 23 | sum += dt 24 | ticks[index] = dt 25 | index = (index + 1) % num 26 | f = 1000 * num / sum 27 | return f 28 | } 29 | 30 | /** @return current fps string formatted to 1 decimal place */ 31 | function fps() { 32 | return f.toFixed(1) 33 | } 34 | 35 | return { 36 | update, 37 | fps 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /public/shader/grass.frag.glsl: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2017 by Mike Linkovich 3 | 4 | precision highp float; 5 | 6 | uniform sampler2D map; 7 | uniform sampler2D heightMap; 8 | uniform vec3 fogColor; 9 | uniform float fogNear; 10 | uniform float fogFar; 11 | uniform float grassFogFar; 12 | 13 | varying vec2 vSamplePos; 14 | varying vec4 vColor; 15 | varying vec2 vUv; 16 | 17 | void main() { 18 | vec4 color = vec4(vColor) * texture2D(map, vUv); 19 | vec4 hdata = texture2D(heightMap, vSamplePos); 20 | 21 | float depth = gl_FragCoord.z / gl_FragCoord.w; 22 | 23 | // make grass transparent as it approachs outer view distance perimeter 24 | color.a = 1.0 - smoothstep(grassFogFar * 0.55, grassFogFar * 0.8, depth); 25 | 26 | // apply terrain lightmap 27 | float light = hdata.g; 28 | color.r *= light; 29 | color.g *= light; 30 | color.b *= light; 31 | 32 | // then apply atmosphere fog 33 | float fogFactor = smoothstep(fogNear, fogFar, depth); 34 | color.rgb = mix(color.rgb, fogColor, fogFactor); 35 | // output 36 | gl_FragColor = color; 37 | } 38 | -------------------------------------------------------------------------------- /src/gmath.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | export const PI2: number = Math.PI * 2.0 5 | 6 | export function sign (n: number) { 7 | return (n > 0 ? 1 : n < 0 ? -1 : 0) 8 | } 9 | 10 | export function roundFrac(n: number, places: number) { 11 | const d = Math.pow(10, places) 12 | return Math.round((n + 0.000000001) * d) / d 13 | } 14 | 15 | export function clamp (n: number, min: number, max: number) { 16 | return Math.min(Math.max(n, min), max) 17 | } 18 | 19 | /** Always positive modulus */ 20 | export function pmod (n: number, m: number) { 21 | return ((n % m + m) % m) 22 | } 23 | 24 | /** A random number from -1.0 to 1.0 */ 25 | export function nrand() { 26 | return Math.random() * 2.0 - 1.0 27 | } 28 | 29 | export function angle (x: number, y: number) { 30 | return pmod(Math.atan2(y, x), PI2) 31 | } 32 | 33 | export function difAngle (a0: number, a1: number) { 34 | const r = pmod(a1, PI2) - pmod(a0, PI2) 35 | return Math.abs(r) < Math.PI ? r : r - PI2 * sign(r) 36 | } 37 | 38 | export function dot (x0: number, y0: number, x1: number, y1: number) : number { 39 | return (x0 * x1 + y0 * y1) 40 | } 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "terrain", 3 | "version": "1.1.2", 4 | "description": "WebGL Grassy Terrain Renderer", 5 | "licence": "CREATIVE COMMONS ATTRIBUTION-NONCOMMERCIAL 4.0 INTERNATIONAL LICENSE", 6 | "author": "Mike Linkovich", 7 | "scripts": { 8 | "serve": "serve -p 3000 public", 9 | "compile": "browserify src/main.ts -p [ tsify --project src/tsconfig.json ] -o public/js/terra.js", 10 | "compile-debug": "browserify --debug src/main.ts -p [ tsify --project src/tsconfig.json ] -o public/js/terra.js", 11 | "watch": "watchify -v --debug src/main.ts -p [ tsify --project src/tsconfig.json ] -o public/js/terra.js", 12 | "build": "browserify src/main.ts -p [ tsify --project src/tsconfig.json ] | uglifyjs -cm -o public/js/terra.js", 13 | "clean": "rm -f public/js/terra.js", 14 | "start": "npm-run-all -p watch serve" 15 | }, 16 | "devDependencies": { 17 | "@types/three": "0.93.10", 18 | "browserify": "^16.5.1", 19 | "serve": "^11.3.0", 20 | "npm-run-all": "^4.1.5", 21 | "tsify": "^4.0.1", 22 | "tslint": "^5.11.0", 23 | "typescript": "3.1.6", 24 | "uglify-js": "^3.8.1", 25 | "watchify": "^3.11.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/browser.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | declare global { 5 | interface Navigator { 6 | standalone?: boolean 7 | } 8 | interface External { 9 | msIsSiteMode(): boolean 10 | } 11 | } 12 | 13 | /** Try to determine if was launched from homescreen/desktop app launcher */ 14 | export const isStandalone = (function(){ 15 | // iOS 16 | if (navigator.standalone !== undefined) 17 | return !!navigator.standalone 18 | // Windows Mobile 19 | if (window.external && window.external.msIsSiteMode) 20 | return !!window.external.msIsSiteMode() 21 | // Chrome 22 | return window.matchMedia('(display-mode: standalone)').matches 23 | }()) 24 | 25 | export const isMobile = (function(){ 26 | const a = !!navigator.userAgent.match(/Android/i) 27 | const bb = !!navigator.userAgent.match(/BlackBerry/i) 28 | const ios = !!navigator.userAgent.match(/iPhone|iPad|iPod/i) 29 | const o = !!navigator.userAgent.match(/Opera Mini/i) 30 | const w = !!navigator.userAgent.match(/IEMobile/i) 31 | const ff = !!navigator.userAgent.match(/\(Mobile/i) 32 | const any = (a || bb || ios || o || w || ff) 33 | return { 34 | Android: a, 35 | BlackBerry: bb, 36 | iOS: ios, 37 | Opera: o, 38 | Windows: w, 39 | FireFox: ff, 40 | any 41 | } 42 | }()) 43 | -------------------------------------------------------------------------------- /src/bufferset.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | export const POSITION = 0x01 5 | export const NORMAL = 0x02 6 | export const COLOR = 0x04 7 | export const UV = 0x08 8 | //export const INDEX = 0x10 9 | export const ALL = POSITION | NORMAL | COLOR | UV // | INDEX 10 | 11 | interface BufferSet { 12 | position?: Float32Array 13 | normal?: Float32Array 14 | color?: Float32Array 15 | uv?: Float32Array 16 | index: Uint16Array 17 | vertexCount: number // useful offsets when filling buffers 18 | indexCount: number 19 | } 20 | 21 | /** 22 | * Creates a bufferset 23 | * @param numVtx Number of vertices 24 | * @param numId Number of indices 25 | * @param b Types of buffers to create (bitflags) 26 | */ 27 | function BufferSet (numVtx: number, numId: number, b?: number): BufferSet { 28 | b = (typeof b === 'number') ? b & ALL : ALL 29 | return { 30 | position: (b & POSITION) ? new Float32Array(numVtx * 3) : undefined, 31 | normal: (b & NORMAL) ? new Float32Array(numVtx * 3) : undefined, 32 | color: (b & COLOR) ? new Float32Array(numVtx * 4) : undefined, 33 | uv: (b & UV) ? new Float32Array(numVtx * 2) : undefined, 34 | index: new Uint16Array(numId), 35 | vertexCount: 0, 36 | indexCount: 0 37 | } 38 | } 39 | 40 | export default BufferSet 41 | -------------------------------------------------------------------------------- /public/img/options.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 20 | 24 | -------------------------------------------------------------------------------- /public/img/fullscreen.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | image/svg+xml 20 | 21 | 24 | 25 | 27 | 28 | 32 | 33 | 37 | 38 | 42 | 43 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /public/shader/water.frag.glsl: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2017 by Mike Linkovich 3 | 4 | precision highp float; 5 | 6 | #define PI 3.141592654 7 | 8 | uniform sampler2D map; 9 | uniform float waterLevel; 10 | uniform vec3 viewPos; 11 | uniform float time; 12 | uniform vec3 waterColor; 13 | uniform vec3 fogColor; 14 | uniform float fogNear; 15 | uniform float fogFar; 16 | 17 | varying vec3 vSurfacePos; 18 | 19 | void main() { 20 | // Get the direction from camera through this point on the surface of the water. 21 | // Then project that on to an upside-down skydome to get the UV from the skydome texture. 22 | // X & Y of plane are moving with viewer, so we only need Z difference to get dir. 23 | vec3 viewDir = normalize(vec3(vSurfacePos.xy, waterLevel - viewPos.z)); 24 | 25 | vec2 uv = vec2( 26 | // horizontal angle converted to a texcoord between 0.0 and 1.0 27 | atan(viewDir.y, viewDir.x) / (PI * 2.0), 28 | // down angle converted to V tex coord between 0.0 and 1.0 29 | asin(-viewDir.z) / (PI / 2.0) 30 | ); 31 | 32 | // wave distortion 33 | float distortScale = 1.0 / length(vSurfacePos - viewPos); 34 | vec2 distort = vec2( 35 | cos((vSurfacePos.x - viewPos.x) / 1.5 + time) + sin((vSurfacePos.y - viewPos.y) / 1.5 + time), 36 | 0.0 37 | ) * distortScale; 38 | uv += distort; 39 | 40 | // Now we can sample the env map 41 | vec4 color = texture2D(map, uv); 42 | 43 | // Mix with water colour 44 | color.rgb = color.rgb * waterColor; 45 | 46 | // Apply fog 47 | float depth = gl_FragCoord.z / gl_FragCoord.w; 48 | float fogFactor = smoothstep(fogNear, fogFar, depth); 49 | color.rgb = mix(color.rgb, fogColor, fogFactor); 50 | gl_FragColor = color; 51 | } 52 | -------------------------------------------------------------------------------- /src/fullscreen.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | declare global { 5 | interface Document { 6 | mozFullScreenElement?: Element 7 | msFullscreenElement?: Element 8 | webkitFullscreenElement?: Element 9 | fullscreenElement?: Element 10 | mozCancelFullScreen?(): void 11 | msExitFullscreen?(): void 12 | msRequestFullscreen?(): void 13 | mozRequestFullScreen?(): void 14 | webkitExitFullscreen?(): void 15 | //mozFullscreenEnabled?: boolean 16 | } 17 | interface Element { 18 | msRequestFullscreen?(): void 19 | mozRequestFullScreen?(): void 20 | webkitRequestFullscreen?(): void 21 | } 22 | } 23 | 24 | export function toggle(el: HTMLElement) { 25 | if (!is()) { 26 | /*if (document.mozFullscreenEnabled === false) { 27 | console.warn("Fullscreen may not be available") 28 | }*/ 29 | if (el.requestFullscreen) { 30 | el.requestFullscreen() 31 | } else if (el.msRequestFullscreen) { 32 | el.msRequestFullscreen() 33 | } else if (el.mozRequestFullScreen) { 34 | el.mozRequestFullScreen() 35 | } else if (el.webkitRequestFullscreen) { 36 | el.webkitRequestFullscreen() 37 | } 38 | } else { 39 | if (document.exitFullscreen) { 40 | document.exitFullscreen() 41 | } else if (document.msExitFullscreen) { 42 | document.msExitFullscreen() 43 | } else if (document.mozCancelFullScreen) { 44 | document.mozCancelFullScreen() 45 | } else if (document.webkitExitFullscreen) { 46 | document.webkitExitFullscreen() 47 | } 48 | } 49 | } 50 | 51 | export function is() { 52 | return !!document.fullscreenElement || !!document.mozFullScreenElement || 53 | !!document.webkitFullscreenElement || !!document.msFullscreenElement 54 | } 55 | -------------------------------------------------------------------------------- /src/anim.ts: -------------------------------------------------------------------------------- 1 | // Simple CSS animations 2 | // 3 | // LICENSE: MIT 4 | // Copyright (c) 2016 by Mike Linkovich 5 | 6 | import {roundFrac} from './gmath' 7 | 8 | const PRECISION = 5 9 | 10 | /** 11 | * Fades element from o0 opacity to o1 opacity in dur milliseconds. 12 | * Invokes complete callback when done. 13 | */ 14 | export function fade ( 15 | el: HTMLElement, o0: number, o1: number, dur: number, complete?: () => void 16 | ) { 17 | const startT = Date.now() 18 | let prevO = roundFrac(o0, PRECISION).toString() 19 | el.style.opacity = prevO 20 | function fadeLoop() { 21 | const t = Date.now() - startT 22 | if (t >= dur) { 23 | el.style.opacity = roundFrac(o1, PRECISION).toString() 24 | if (complete) complete() 25 | } 26 | else { 27 | // round off so style value isn't too weird 28 | const o = roundFrac(o0 + t / dur * (o1 - o0), PRECISION).toString() 29 | if (o !== prevO) { 30 | // only update style if value has changed 31 | el.style.opacity = o 32 | prevO = o 33 | } 34 | requestAnimationFrame(fadeLoop) 35 | } 36 | } 37 | requestAnimationFrame(fadeLoop) 38 | } 39 | 40 | /** 41 | * Go from 0 opacity to 1 in dur milliseconds. 42 | * @param el Element to fade 43 | * @param dur Fade duration in ms 44 | * @param complete Callback on complete 45 | */ 46 | export function fadeIn(el: HTMLElement, dur: number, complete?: () => void) { 47 | fade(el, 0, 1, dur, complete) 48 | } 49 | 50 | /** 51 | * Go from 1 opacity to 0 in dur milliseconds. 52 | * @param el Element to fade 53 | * @param dur Fade duration in ms 54 | * @param complete Callback on complete 55 | */ 56 | export function fadeOut(el: HTMLElement, dur: number, complete?: () => void) { 57 | fade(el, 1, 0, dur, complete) 58 | } 59 | -------------------------------------------------------------------------------- /public/shader/terrain.frag.glsl: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2017 by Mike Linkovich 3 | 4 | precision highp float; 5 | 6 | #define TRANSITION_LOW (%%TRANSITION_LOW%%) 7 | #define TRANSITION_HIGH (%%TRANSITION_HIGH%%) 8 | #define TRANSITION_NOISE 0.06 9 | 10 | const vec3 LIGHT_COLOR = vec3(1.0, 1.0, 0.9); 11 | const vec3 DIRT_COLOR = vec3(0.77, 0.67, 0.45); 12 | 13 | uniform sampler2D map1; 14 | uniform sampler2D map2; 15 | uniform sampler2D heightMap; 16 | uniform vec3 fogColor; 17 | uniform float fogNear; 18 | uniform float fogFar; 19 | uniform float grassFogFar; 20 | 21 | varying vec2 vUv; 22 | varying vec2 vSamplePos; 23 | 24 | void main() { 25 | vec4 hdata = texture2D(heightMap, vSamplePos); 26 | float altitude = hdata.r; 27 | // perturb altitude with some noise using the B channel. 28 | float noise = hdata.b; 29 | altitude += noise * TRANSITION_NOISE - (TRANSITION_NOISE / 2.0); 30 | 31 | // Determine whether this position is more grass or more dirt 32 | float grassAmount = (clamp(altitude, TRANSITION_LOW, TRANSITION_HIGH) - TRANSITION_LOW) 33 | * (1.0 / (TRANSITION_HIGH - TRANSITION_LOW)); 34 | 35 | // Sample both textures and mix proportionately 36 | vec3 color = mix( 37 | texture2D(map2, vUv).rgb, 38 | texture2D(map1, vUv).rgb, 39 | grassAmount 40 | ); 41 | 42 | vec3 light = hdata.g * LIGHT_COLOR; 43 | float depth = gl_FragCoord.z / gl_FragCoord.w; 44 | 45 | // If terrain is covered by grass geometry, blend color to 'dirt' 46 | float dirtFactor = 1.0 - smoothstep(grassFogFar * 0.2, grassFogFar * 0.65, depth); 47 | // If we're not on a grass terrain type, don't shade it... 48 | dirtFactor *= grassAmount; 49 | float dirtShade = (color.r + color.g + color.b) / 3.0; 50 | 51 | // Compute terrain color 52 | color = mix(color, DIRT_COLOR * dirtShade, dirtFactor) * light; 53 | 54 | // then apply atmosphere fog 55 | float fogFactor = smoothstep(fogNear, fogFar, depth); 56 | color = mix(color, fogColor, fogFactor); 57 | gl_FragColor = vec4(color, 1.0); 58 | } 59 | -------------------------------------------------------------------------------- /src/input.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | export interface State { 5 | up: number 6 | down: number 7 | left: number 8 | right: number 9 | forward: number 10 | back: number 11 | pitchup: number 12 | pitchdown: number 13 | } 14 | 15 | export const state: State = { 16 | up: 0, 17 | down: 0, 18 | left: 0, 19 | right: 0, 20 | forward: 0, 21 | back: 0, 22 | pitchup: 0, 23 | pitchdown: 0 24 | } 25 | 26 | const keyStates = new Array(256).map(b => false) 27 | 28 | // Any listeners the app has set up 29 | const keyPressListeners: {[id: string]: () => any} = {} 30 | 31 | function setState (k: number, s: number) { 32 | const cs = state; 33 | // arrow keys L/R/F/B 34 | if (k === 37) 35 | cs.left = s 36 | else if (k === 39) 37 | cs.right = s 38 | else if (k === 38) 39 | cs.forward = s 40 | else if (k === 40) 41 | cs.back = s 42 | else if (k === 87) // W 43 | cs.up = s 44 | else if (k === 83) // S 45 | cs.down = s 46 | else if (k === 81) // Q 47 | cs.pitchup = s 48 | else if (k === 65) // A 49 | cs.pitchdown = s 50 | } 51 | 52 | function onKeyDown (ev:KeyboardEvent) { 53 | if (!keyStates[ev.keyCode]) { 54 | setState(ev.keyCode, 1.0) 55 | keyStates[ev.keyCode] = true 56 | const codeStr = ev.keyCode.toString() 57 | if (typeof keyPressListeners[codeStr] === 'function') { 58 | keyPressListeners[codeStr]() 59 | } 60 | } 61 | } 62 | 63 | function onKeyUp (ev:KeyboardEvent) { 64 | if (keyStates[ev.keyCode]) { 65 | keyStates[ev.keyCode] = false 66 | setState(ev.keyCode, 0.0) 67 | } 68 | } 69 | 70 | let initialized = false 71 | 72 | export function init() { 73 | if (initialized) return 74 | document.addEventListener('keydown', onKeyDown, true) 75 | document.addEventListener('keyup', onKeyUp, true) 76 | initialized = true 77 | } 78 | 79 | export function getKeyState(code: number) { 80 | return keyStates[code] 81 | } 82 | 83 | export function setKeyPressListener(code: number, fn: () => void) { 84 | keyPressListeners[code.toString()] = fn 85 | } 86 | -------------------------------------------------------------------------------- /src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended"], 3 | "defaultSeverity": "warning", 4 | "rules": { 5 | "array-type": false, 6 | "arrow-parens": false, 7 | "ban-types": false, 8 | "class-name": false, 9 | "comment-format": [false, "check-space"], 10 | "curly": false, 11 | "forin": true, 12 | "indent": [true, "tabs"], 13 | "interface-name": false, 14 | "interface-over-type-literal": false, 15 | "jsdoc-format": false, 16 | "max-line-length": [false], 17 | "member-access": false, 18 | "no-bitwise": false, 19 | "no-console": false, 20 | "no-duplicate-variable": true, 21 | "no-empty": false, 22 | "no-eval": true, 23 | "no-internal-module": false, 24 | "no-namespace": false, 25 | "no-reference": false, 26 | "no-reference-import": true, 27 | "no-shadowed-variable": false, 28 | "no-string-literal": false, 29 | "no-trailing-whitespace": true, 30 | "no-var-keyword": true, 31 | "no-unused-expression": false, 32 | "no-unused-variable": true, 33 | "object-literal-key-quotes": [true, "as-needed"], 34 | "object-literal-sort-keys": false, 35 | "one-line": [true, "check-open-brace", "check-whitespace"], 36 | "one-variable-per-declaration": false, 37 | "only-arrow-functions": false, 38 | "ordered-imports": false, 39 | "prefer-for-of": false, 40 | "prefer-method-signature": true, 41 | "quotemark": [false, "double"], 42 | "semicolon": [true, "never"], 43 | "space-before-function-paren": false, 44 | "trailing-comma": false, 45 | "triple-equals": [true, "allow-null-check"], 46 | "typedef-whitespace": [ 47 | false, 48 | { 49 | "call-signature": "nospace", 50 | "index-signature": "nospace", 51 | "parameter": "nospace", 52 | "property-declaration": "nospace", 53 | "variable-declaration": "nospace" 54 | } 55 | ], 56 | "unified-signatures": false, 57 | "variable-name": [true, "ban-keywords"], 58 | "whitespace": [ 59 | true, 60 | "check-branch", 61 | "check-decl", 62 | "check-operator", 63 | "check-type" 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/water.ts: -------------------------------------------------------------------------------- 1 | // 2 | // Water mesh 3 | // A flat plane extending to frustum depth that follows 4 | // viewer position horizontally. 5 | // Shader does environmental mapping to reflect skydome, 6 | // blend with water colour, and apply fog in distance. 7 | // 8 | 9 | // Uses water shaders (see: shader/water.*.glsl) 10 | 11 | // LICENSE: MIT 12 | // Copyright (c) 2016 by Mike Linkovich 13 | 14 | /// 15 | import {Vec3, Color} from './vec' 16 | 17 | export interface WaterOptions { 18 | envMap: THREE.Texture 19 | vertScript: string 20 | fragScript: string 21 | waterLevel: number 22 | waterColor: Color 23 | fogColor: Color 24 | fogNear: number 25 | fogFar: number 26 | } 27 | 28 | let _time = 0 29 | 30 | /** Create Water Mesh */ 31 | export function createMesh (opts: WaterOptions) { 32 | opts.envMap.wrapS = opts.envMap.wrapT = THREE.RepeatWrapping 33 | opts.envMap.minFilter = opts.envMap.magFilter = THREE.LinearFilter 34 | opts.envMap.generateMipmaps = false 35 | const mat = new THREE.RawShaderMaterial({ 36 | uniforms: { 37 | time: {type: '1f', value: 0.0}, 38 | viewPos: {type: '3f', value: [0.0, 0.0, 10.0]}, 39 | map: {type: 't', value: opts.envMap}, 40 | waterLevel: {type: '1f', value: opts.waterLevel}, 41 | waterColor: {type: '3f', value: Color.toArray(opts.waterColor)}, 42 | fogColor: {type: '3f', value: Color.toArray(opts.fogColor)}, 43 | fogNear: {type: 'f', value: 1.0}, 44 | fogFar: {type: 'f', value: opts.fogFar * 1.5}, 45 | }, 46 | vertexShader: opts.vertScript, 47 | fragmentShader: opts.fragScript 48 | }) 49 | const mesh = new THREE.Mesh( 50 | new THREE.PlaneBufferGeometry(2000.0, 2000.0), 51 | mat 52 | ) 53 | mesh.frustumCulled = false 54 | _time = Date.now() 55 | return mesh 56 | } 57 | 58 | export function update (mesh: THREE.Mesh, viewPos: Vec3) { 59 | mesh.position.x = viewPos.x 60 | mesh.position.y = viewPos.y 61 | const mat = mesh.material as THREE.RawShaderMaterial 62 | const vp = mat.uniforms['viewPos'].value as number[] 63 | vp[0] = viewPos.x 64 | vp[1] = viewPos.y 65 | vp[2] = viewPos.z 66 | 67 | mat.uniforms['time'].value = (Date.now() - _time) / 250.0 68 | } 69 | -------------------------------------------------------------------------------- /src/loader.ts: -------------------------------------------------------------------------------- 1 | // Loader that provides a dictionary of named assets 2 | // LICENSE: MIT 3 | // Copyright (c) 2016 by Mike Linkovich 4 | 5 | /// 6 | 7 | export interface Assets { 8 | images: {[id: string]: HTMLImageElement} 9 | text: {[id: string]: string} 10 | textures: {[id: string]: THREE.Texture} 11 | } 12 | 13 | export interface AssetDescription { 14 | name: string 15 | url: string 16 | } 17 | 18 | export interface AssetList { 19 | images?: AssetDescription[] 20 | text?: AssetDescription[] 21 | textures?: AssetDescription[] 22 | } 23 | 24 | /** 25 | * Create a Loader instance 26 | */ 27 | function Loader() { 28 | 29 | let isLoading = false 30 | let totalToLoad = 0 31 | let numLoaded = 0 32 | let numFailed = 0 33 | let success_cb: ((a: Assets) => any) | undefined 34 | let progress_cb: ((p: number) => any) | undefined 35 | let error_cb: ((e: string) => any) | undefined 36 | let done_cb: ((ok: boolean) => any) | undefined 37 | const assets: Assets = {images: {}, text: {}, textures: {}} 38 | 39 | /** 40 | * Start loading a list of assets 41 | */ 42 | function load( 43 | assetList: AssetList, 44 | success?: (a: Assets) => any, 45 | progress?: (p: number) => any, 46 | error?: (e: string) => any, 47 | done?: (ok: boolean) => any 48 | ) { 49 | success_cb = success 50 | progress_cb = progress 51 | error_cb = error 52 | done_cb = done 53 | totalToLoad = 0 54 | numLoaded = 0 55 | numFailed = 0 56 | isLoading = true 57 | 58 | if (assetList.text) { 59 | totalToLoad += assetList.text.length 60 | for (let i = 0; i < assetList.text.length; ++i) { 61 | loadText(assetList.text[i]) 62 | } 63 | } 64 | if (assetList.images) { 65 | totalToLoad += assetList.images.length 66 | for (let i = 0; i < assetList.images.length; ++i) { 67 | loadImage(assetList.images[i]) 68 | } 69 | } 70 | if (assetList.textures) { 71 | totalToLoad += assetList.textures.length 72 | for (let i = 0; i < assetList.textures.length; ++i) { 73 | loadTexture(assetList.textures[i]) 74 | } 75 | } 76 | } 77 | 78 | function loadText (ad: AssetDescription) { 79 | console.log('loading ' + ad.url) 80 | const req = new XMLHttpRequest() 81 | req.overrideMimeType('*/*') 82 | req.onreadystatechange = function() { 83 | if (req.readyState === 4) { 84 | if (req.status === 200) { 85 | assets.text[ad.name] = req.responseText 86 | console.log('loaded ' + ad.name) 87 | doProgress() 88 | } else { 89 | doError("Error " + req.status + " loading " + ad.url) 90 | } 91 | } 92 | } 93 | req.open('GET', ad.url) 94 | req.send() 95 | } 96 | 97 | function loadImage (ad: AssetDescription) { 98 | const img = new Image() 99 | assets.images[ad.name] = img 100 | img.onload = doProgress 101 | img.onerror = doError 102 | img.src = ad.url 103 | } 104 | 105 | function loadTexture (ad: AssetDescription) { 106 | assets.textures[ad.name] = new THREE.TextureLoader().load(ad.url, doProgress) 107 | } 108 | 109 | function doProgress() { 110 | numLoaded += 1 111 | progress_cb && progress_cb(numLoaded / totalToLoad) 112 | tryDone() 113 | } 114 | 115 | function doError (e: any) { 116 | error_cb && error_cb(e) 117 | numFailed += 1 118 | tryDone() 119 | } 120 | 121 | function tryDone() { 122 | if (!isLoading) 123 | return true 124 | if (numLoaded + numFailed >= totalToLoad) { 125 | const ok = !numFailed 126 | if (ok && success_cb) success_cb(assets) 127 | done_cb && done_cb(ok) 128 | isLoading = false 129 | } 130 | return !isLoading 131 | } 132 | 133 | /** 134 | * Public interface 135 | */ 136 | return { 137 | load, 138 | getAssets: () => assets 139 | } 140 | 141 | } // end Loader 142 | 143 | interface Loader extends ReturnType {} 144 | 145 | export default Loader 146 | -------------------------------------------------------------------------------- /public/css/style.css: -------------------------------------------------------------------------------- 1 | html, body, td, th, input, select, button { 2 | font-family: Helvetica,Arial,sans-serif; 3 | font-size: 12px; 4 | color: #666; 5 | } 6 | 7 | html, body { 8 | height: 100%; 9 | } 10 | 11 | body { 12 | background: #FFF; 13 | margin: 0; 14 | padding: 0; 15 | } 16 | 17 | a { 18 | color: #444; 19 | text-decoration: none; 20 | } 21 | a:hover { 22 | color: #333; 23 | } 24 | 25 | button { 26 | font-size: 1.25em; 27 | font-weight: bold; 28 | color: #888; 29 | background: #FFF; 30 | border-style: solid; 31 | border-width: 1px; 32 | border-color: #CCC; 33 | padding: 0.5em 1em 0.5em 1em; 34 | cursor: pointer; 35 | } 36 | button:hover { 37 | background: #EEE; 38 | color: #666; 39 | } 40 | 41 | select { 42 | font-size: 1.2em; 43 | } 44 | 45 | /** classes ************************************************/ 46 | 47 | #content_container { 48 | position: relative; 49 | width: 100%; 50 | height: 100%; 51 | 52 | /* flex/box betas */ 53 | display:-webkit-box; 54 | -webkit-box-pack:center; 55 | -webkit-box-align:center; 56 | 57 | /* flex (WC3) */ 58 | display: flex; 59 | flex-direction: row; 60 | align-items: center; 61 | justify-content: center; 62 | } 63 | 64 | #app_container { 65 | width: 100%; 66 | height: 100%; 67 | } 68 | 69 | #app_ui_container { 70 | position: relative; 71 | width: 100%; 72 | height: 100%; 73 | 74 | /* flex/box betas */ 75 | display:-webkit-box; 76 | -webkit-box-pack:center; 77 | -webkit-box-align:center; 78 | 79 | /* flex (WC3) */ 80 | display: flex; 81 | flex-direction: column; 82 | align-items: center; 83 | justify-content: center; 84 | } 85 | 86 | #app_canvas_container { 87 | position: absolute; 88 | left: 0px; 89 | top: 0px; 90 | width: 100%; 91 | height: 100%; 92 | z-index: -1; 93 | overflow: hidden; 94 | } 95 | 96 | #title_block { 97 | text-align: center; 98 | margin-bottom: 16px; 99 | } 100 | 101 | #title_bar { 102 | position: absolute; 103 | top: 0; 104 | left: 0; 105 | width: 100%; 106 | } 107 | 108 | #title_bar_left { 109 | font-size: 1.6em; 110 | float: left; 111 | padding: 0.25em 0.5em; 112 | } 113 | #title_bar_left a { 114 | color: rgba(255,255,255,0.75); 115 | text-decoration: none; 116 | } 117 | #title_bar_left a:hover { 118 | color: rgba(255,255,255,0.95); 119 | } 120 | 121 | #title_bar_right { 122 | float: right; 123 | padding: 0.5em; 124 | } 125 | 126 | #loading_block { 127 | text-align: center; 128 | width: 100%; 129 | margin: auto 0; 130 | overflow-y: auto; 131 | } 132 | #loading_title { 133 | color: #CCC; 134 | font-size: 72px; 135 | font-weight: bold; 136 | } 137 | #loading_text { 138 | color: #CCC; 139 | font-style: italic; 140 | font-size: 11px; 141 | } 142 | #loading_bar_outer { 143 | background: #EEE; 144 | height: 2px; 145 | width: 96%; 146 | margin-left: auto; 147 | margin-right: auto; 148 | /*margin-bottom: 8px;*/ 149 | } 150 | #loading_bar { 151 | background: #CCC; 152 | height: 100%; 153 | width: 0%; 154 | } 155 | 156 | #loading_container { 157 | position: relative; 158 | width: 100%; 159 | height: 100%; 160 | 161 | /* flex/box betas */ 162 | display:-webkit-box; 163 | -webkit-box-pack: center; 164 | -webkit-box-align: center; 165 | 166 | /* flex (WC3) */ 167 | display: flex; 168 | flex-direction: column; 169 | align-items: center; 170 | justify-content: center; 171 | } 172 | 173 | #txt_controls { 174 | padding: 1em 0em; 175 | } 176 | 177 | #notification { 178 | text-align: center; 179 | position: absolute; 180 | left: 0; 181 | bottom: 1em; 182 | width: 100%; 183 | overflow: hidden; 184 | } 185 | #notification_text { 186 | font-size: 15px; 187 | font-weight: bold; 188 | color: #AAA; 189 | background: rgba(0,0,0,0.5); 190 | display: inline-block; 191 | padding: 0.5em 1em; 192 | } 193 | 194 | #logger { 195 | position: absolute; 196 | display: inline-block; 197 | right: 0; 198 | bottom: 0; 199 | padding: 0.25em 0.5em; 200 | font-family: monospace; 201 | color: #8C3; 202 | background: rgba(0,0,0,0.5); 203 | } 204 | 205 | .btn_icon { 206 | margin-left: 1em; 207 | cursor: pointer; 208 | } 209 | 210 | .tbl_config { 211 | display: inline-block; 212 | } 213 | 214 | .title_wordmark { 215 | font-size: 40px; 216 | } 217 | 218 | .key { 219 | background: #EEE; 220 | padding: 0.1em 0.5em; 221 | } 222 | 223 | .cfg_value { 224 | font-size: 1.25em; 225 | font-weight: bold; 226 | } 227 | 228 | .noselect { 229 | -webkit-user-select: none; 230 | -ms-user-select: none; 231 | -moz-user-select: none; 232 | user-select: none; 233 | } 234 | -------------------------------------------------------------------------------- /src/terramap.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | /// 5 | import {pmod} from './gmath' 6 | import {Vec2, Vec3} from './vec' 7 | import Heightfield from './heightfield' 8 | 9 | /** 10 | * Create a texture containing height, lighting, etc. data 11 | * encoded into RGBA channels. 12 | */ 13 | export function createTexture (hf: Heightfield, lightDir: Vec3, imgWind: HTMLImageElement) { 14 | const canvas = document.createElement('canvas') 15 | const canvasWidth = hf.xCount + 1 16 | const canvasHeight = hf.yCount + 1 17 | canvas.width = canvasWidth 18 | canvas.height = canvasHeight 19 | const ctx = canvas.getContext('2d')! 20 | const imgData = ctx.getImageData(0, 0, canvasWidth, canvasHeight) 21 | // Fill R (height) and G (light) values from heightfield data and computed light 22 | computeData(hf, lightDir, imgData.data) 23 | // Add wind intensity to B channel 24 | addWindData(imgWind, imgData.data) 25 | ctx.putImageData(imgData, 0, 0) 26 | const tex = new THREE.Texture(canvas) 27 | tex.wrapS = tex.wrapT = THREE.RepeatWrapping 28 | tex.needsUpdate = true 29 | return tex 30 | } 31 | 32 | /** 33 | * Pack heights and lighting into RGBA data 34 | */ 35 | function computeData (hf: Heightfield, lightDir: Vec3, buf: Uint8ClampedArray) { 36 | const vnorms = hf.vtxNormals 37 | const w = hf.xCount + 1 38 | const h = hf.yCount + 1 39 | const n = Vec3.create() 40 | const tStart = Date.now() 41 | for (let y = 0; y < h; ++y) { 42 | for (let x = 0; x < w; ++x) { 43 | const iSrc = y * w + x 44 | const iDst = (h - y - 1) * w + x 45 | // Get height, scale & store in R component 46 | buf[iDst * 4 + 0] = Math.round(hf.heights[iSrc] / hf.maxHeight * 255.0) 47 | // Get normal at this location to compute light 48 | let ni = iSrc * 3 49 | n.x = vnorms[ni++] 50 | n.y = vnorms[ni++] 51 | n.z = vnorms[ni++] 52 | // Compute light & store in G component 53 | let light = Math.max(-Vec3.dot(n, lightDir), 0.0) 54 | light *= computeShade(hf, lightDir, x, y) 55 | buf[iDst * 4 + 1] = Math.round(light * 255.0) 56 | //buf[iDst * 4 + 2] = ... // B channel for terrain type? 57 | buf[iDst * 4 + 3] = 255 // must set alpha to some value > 0 58 | } 59 | } 60 | const dt = Date.now() - tStart 61 | console.log(`computed terrain data texture (${w}x${h}) values in ${dt}ms`) 62 | return buf 63 | } 64 | 65 | const _v = Vec2.create() 66 | 67 | function computeShade (hf: Heightfield, lightDir: Vec3, ix: number, iy: number) { 68 | // Make a normalized 2D direction vector we'll use to walk horizontally 69 | // toward the lightsource until z reaches max height 70 | const shadGradRange = 5.0 71 | const hdir = _v 72 | const w = hf.xCount + 1 73 | const h = hf.yCount + 1 74 | const i = iy * w + ix 75 | let height = hf.heights[i] // height at this point 76 | hdir.x = -lightDir.x 77 | hdir.y = -lightDir.y 78 | Vec2.normalize(hdir, hdir) 79 | const zstep = (Vec2.length(hdir) / Vec2.length(lightDir)) * (-lightDir.z) 80 | let x = ix 81 | let y = iy 82 | // Walk along the direction until we discover this point 83 | // is in shade or the light vector is too high to be shaded 84 | while (height < hf.maxHeight) { 85 | x += hdir.x 86 | y += hdir.y 87 | height += zstep 88 | const qx = pmod(Math.round(x), w) 89 | const qy = pmod(Math.round(y), h) 90 | const sampleHeight = hf.heights[qy * w + qx] 91 | if (sampleHeight > height) { 92 | if (sampleHeight - height > shadGradRange) 93 | return 0.7 // this point is in shade 94 | else 95 | return 0.7 + 0.3 * (shadGradRange - (sampleHeight - height)) / shadGradRange 96 | } 97 | } 98 | return 1.0 99 | } 100 | 101 | /** 102 | * Put wind data from the wind image to the b channel 103 | */ 104 | function addWindData (imgWind: HTMLImageElement, buf: Uint8ClampedArray) { 105 | const canvas = document.createElement('canvas') 106 | const w = imgWind.naturalWidth 107 | const h = imgWind.naturalHeight 108 | canvas.width = w 109 | canvas.height = h 110 | const ctxSrc = canvas.getContext('2d')! 111 | ctxSrc.drawImage(imgWind, 0, 0) 112 | const windData = ctxSrc.getImageData(0, 0, w, h).data 113 | for (let y = 0; y < h; ++y) { 114 | for (let x = 0; x < w; ++x) { 115 | const i = (y * w + x) * 4 116 | // Get R channel from src. We only use the single channel 117 | // because assume src img is grayscale. 118 | const p = windData[i] 119 | // Now set the B channel of the buffer we're writing to 120 | buf[i + 2] = p 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | Terra - Grassy Terrain Renderer 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 |
23 | 24 |
25 | 26 |
27 | 28 | 35 | 36 |
37 |
terra
38 |

© 2017-2020 by Mike Linkovich | spacejack.github.io | source

39 |

Made with three.js

40 |
41 |
42 |
43 |
loading
44 | 84 |
85 | 86 | 87 | 88 | 89 | 90 |
93 |
94 |
95 | 96 | 97 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /src/vec.ts: -------------------------------------------------------------------------------- 1 | // Vector Math with library-agnostic interface types. 2 | // i.e. Any object with matching property names will work, 3 | // whether three.js, cannon.js, etc. 4 | // 5 | // LICENSE: MIT 6 | // Copyright (c) 2016 by Mike Linkovich 7 | 8 | /** 2D Vector type */ 9 | export interface Vec2 { 10 | x: number 11 | y: number 12 | } 13 | 14 | /** 3D Vector type */ 15 | export interface Vec3 { 16 | x: number 17 | y: number 18 | z: number 19 | } 20 | 21 | /** RGB Color */ 22 | export interface Color { 23 | r: number 24 | g: number 25 | b: number 26 | } 27 | 28 | /** 29 | * 3D Vector functions 30 | */ 31 | export namespace Vec2 { 32 | export function create (x?: number, y?: number) { 33 | return { 34 | x: (typeof x === 'number') ? x : 0.0, 35 | y: (typeof y === 'number') ? y : 0.0 36 | } 37 | } 38 | 39 | export function clone(v: Vec2) { 40 | return create(v.x, v.y) 41 | } 42 | 43 | export function set (v: Vec2, x: number, y: number) { 44 | v.x = x 45 | v.y = y 46 | } 47 | 48 | export function copy (src: Vec2, out: Vec2) { 49 | out.x = src.x 50 | out.y = src.y 51 | } 52 | 53 | export function length (v: Vec2) { 54 | return Math.sqrt(v.x * v.x + v.y * v.y) 55 | } 56 | 57 | export function setLength (v: Vec2, l: number, out: Vec2) { 58 | let s = length(v) 59 | if (s > 0.0) { 60 | s = l / s 61 | out.x = v.x * s 62 | out.y = v.y * s 63 | } 64 | else { 65 | out.x = l 66 | out.y = 0.0 67 | } 68 | } 69 | 70 | export function dist(v0: Vec2, v1: Vec2) { 71 | const dx = v1.x - v0.x 72 | const dy = v1.y - v0.y 73 | return Math.sqrt(dx * dx + dy * dy) 74 | } 75 | 76 | export function normalize (v: Vec2, out: Vec2) { 77 | setLength(v, 1.0, out) 78 | } 79 | 80 | export function dot (v0: Vec2, v1: Vec2) { 81 | return (v0.x * v1.x + v0.y * v1.y) 82 | } 83 | 84 | export function det (v0: Vec2, v1: Vec2) { 85 | return (v0.x * v1.y - v0.y * v1.x) 86 | } 87 | 88 | /** Rotate v by r radians, result in out. (v and out can reference the same Vec2 object) */ 89 | export function rotate (v: Vec2, r: number, out: Vec2) { 90 | const c = Math.cos(r), 91 | s = Math.sin(r), 92 | x = v.x, y = v.y 93 | out.x = x * c - y * s 94 | out.y = x * s + y * c 95 | } 96 | 97 | /** Uses pre-computed cos & sin values of rotation angle */ 98 | export function rotateCS (v: Vec2, c: number, s: number, out: Vec2) { 99 | const x = v.x, y = v.y 100 | out.x = x * c - y * s 101 | out.y = x * s + y * c 102 | } 103 | 104 | /** nx,ny should be normalized; vx,vy length will be preserved */ 105 | export function reflect (v: Vec2, n: Vec2, out: Vec2) { 106 | const d = dot(n, v) 107 | out.x = v.x - 2.0 * d * n.x 108 | out.y = v.y - 2.0 * d * n.y 109 | } 110 | 111 | export function toArray (v: Vec2) { 112 | return [v.x, v.y] 113 | } 114 | } 115 | 116 | /** 117 | * 3D Vector functions 118 | */ 119 | export namespace Vec3 { 120 | export function create (x?: number, y?: number, z?: number) { 121 | return { 122 | x: (typeof x === 'number') ? x : 0.0, 123 | y: (typeof y === 'number') ? y : 0.0, 124 | z: (typeof z === 'number') ? z : 0.0 125 | } 126 | } 127 | 128 | export function clone(v: Vec3) { 129 | return create(v.x, v.y, v.z) 130 | } 131 | 132 | export function set (v: Vec3, x: number, y: number, z: number) { 133 | v.x = x; v.y = y; v.z = z 134 | } 135 | 136 | export function copy (src: Vec3, out: Vec3) { 137 | out.x = src.x 138 | out.y = src.y 139 | out.z = src.z 140 | } 141 | 142 | export function length (v: Vec3) { 143 | return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z) 144 | } 145 | 146 | export function setLength (v: Vec3, l: number, out: Vec3) { 147 | let s = length(v) 148 | if (s > 0.0) { 149 | s = l / s 150 | out.x = v.x * s 151 | out.y = v.y * s 152 | out.z = v.z * s 153 | } else { 154 | out.x = l 155 | out.y = 0.0 156 | out.z = 0.0 157 | } 158 | } 159 | 160 | export function dist(v0: Vec3, v1: Vec3) { 161 | const dx = v1.x - v0.x 162 | const dy = v1.y - v0.y 163 | const dz = v1.z - v0.z 164 | return Math.sqrt(dx * dx + dy * dy + dz * dz) 165 | } 166 | 167 | export function normalize (v: Vec3, out: Vec3) { 168 | Vec3.setLength(v, 1.0, out) 169 | } 170 | 171 | export function dot (a: Vec3, b: Vec3) { 172 | return a.x * b.x + a.y * b.y + a.z * b.z 173 | } 174 | 175 | export function cross (a: Vec3, b: Vec3, out: Vec3) { 176 | const ax = a.x, ay = a.y, az = a.z, 177 | bx = b.x, by = b.y, bz = b.z 178 | out.x = ay * bz - az * by 179 | out.y = az * bx - ax * bz 180 | out.z = ax * by - ay * bx 181 | } 182 | 183 | export function toArray (v: Vec3) { 184 | return [v.x, v.y, v.z] 185 | } 186 | } 187 | 188 | /** 189 | * RGB Color functions 190 | */ 191 | export namespace Color { 192 | export function create (r?: number, g?: number, b?: number) { 193 | return { 194 | r: (typeof r === 'number') ? r : 0.0, 195 | g: (typeof g === 'number') ? g : 0.0, 196 | b: (typeof b === 'number') ? b : 0.0 197 | } 198 | } 199 | 200 | export function toArray (c: Color) { 201 | return [c.r, c.g, c.b] 202 | } 203 | 204 | export function to24bit (c: Color) { 205 | return (c.r * 255) << 16 ^ (c.g * 255) << 8 ^ (c.b * 255) << 0 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/simplex.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * A speed-improved perlin and simplex noise algorithms for 2D. 3 | * 4 | * Based on example code by Stefan Gustavson (stegu@itn.liu.se). 5 | * Optimisations by Peter Eastman (peastman@drizzle.stanford.edu). 6 | * Better rank ordering method by Stefan Gustavson in 2012. 7 | * Converted to Javascript by Joseph Gentle. 8 | * 9 | * Version 2012-03-09 10 | * 11 | * This code was placed in the public domain by its original author, 12 | * Stefan Gustavson. You may use it as you see fit, but 13 | * attribution is appreciated. 14 | * 15 | * -------------------- 16 | * TypeScriptified 2016 17 | */ 18 | 19 | class Grad { 20 | x: number 21 | y: number 22 | z: number 23 | 24 | constructor (x: number, y: number, z: number) { 25 | this.x = x 26 | this.y = y 27 | this.z = z 28 | } 29 | 30 | dot2 (x: number, y: number) { 31 | return this.x * x + this.y * y 32 | } 33 | 34 | dot3 (x: number, y: number, z: number) { 35 | return this.x * x + this.y * y + this.z * z 36 | } 37 | } 38 | 39 | const F2 = 0.5 * (Math.sqrt(3) - 1) 40 | const G2 = (3 - Math.sqrt(3)) / 6 41 | 42 | const perm = new Array(512) 43 | const gradP = new Array(512) 44 | 45 | const grad3 = [ 46 | new Grad(1,1,0), new Grad(-1,1,0), new Grad(1,-1,0), new Grad(-1,-1,0), 47 | new Grad(1,0,1), new Grad(-1,0,1), new Grad(1,0,-1), new Grad(-1,0,-1), 48 | new Grad(0,1,1), new Grad(0,-1,1), new Grad(0,1,-1), new Grad(0,-1,-1) 49 | ] 50 | 51 | const p = [ 52 | 151,160,137,91,90,15, 53 | 131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23, 54 | 190, 6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33, 55 | 88,237,149,56,87,174,20,125,136,171,168, 68,175,74,165,71,134,139,48,27,166, 56 | 77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244, 57 | 102,143,54, 65,25,63,161, 1,216,80,73,209,76,132,187,208, 89,18,169,200,196, 58 | 135,130,116,188,159,86,164,100,109,198,173,186, 3,64,52,217,226,250,124,123, 59 | 5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42, 60 | 223,183,170,213,119,248,152, 2,44,154,163, 70,221,153,101,155,167, 43,172,9, 61 | 129,22,39,253, 19,98,108,110,79,113,224,232,178,185, 112,104,218,246,97,228, 62 | 251,34,242,193,238,210,144,12,191,179,162,241, 81,51,145,235,249,14,239,107, 63 | 49,192,214, 31,181,199,106,157,184, 84,204,176,115,121,50,45,127, 4,150,254, 64 | 138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180 65 | ] 66 | 67 | // This isn't a very good seeding function, but it works ok. It supports 2^16 68 | // different seed values. Write something better if you need more seeds. 69 | function seed (seed: number) { 70 | if (seed > 0 && seed < 1) { 71 | // Scale the seed out 72 | seed *= 65536 73 | } 74 | 75 | seed = Math.floor(seed) 76 | if (seed < 256) { 77 | seed |= seed << 8 78 | } 79 | 80 | for (let i = 0; i < 256; i++) { 81 | let v: number 82 | if (i & 1) { 83 | v = p[i] ^ (seed & 255) 84 | } else { 85 | v = p[i] ^ ((seed >> 8) & 255) 86 | } 87 | 88 | perm[i] = perm[i + 256] = v 89 | gradP[i] = gradP[i + 256] = grad3[v % 12] 90 | } 91 | } 92 | 93 | seed(0) 94 | 95 | // 2D simplex noise 96 | export default function simplex(xin: number, yin: number) { 97 | let n0: number, n1: number, n2: number // Noise contributions from the three corners 98 | // Skew the input space to determine which simplex cell we're in 99 | const s = (xin + yin) * F2 // Hairy factor for 2D 100 | let i = Math.floor(xin + s) 101 | let j = Math.floor(yin + s) 102 | const t = (i + j) * G2 103 | const x0 = xin - i + t // The x,y distances from the cell origin, unskewed. 104 | const y0 = yin - j + t 105 | // For the 2D case, the simplex shape is an equilateral triangle. 106 | // Determine which simplex we are in. 107 | let i1: number, j1: number // Offsets for second (middle) corner of simplex in (i,j) coords 108 | if (x0 > y0) { // lower triangle, XY order: (0,0)->(1,0)->(1,1) 109 | i1 = 1 110 | j1 = 0 111 | } else { // upper triangle, YX order: (0,0)->(0,1)->(1,1) 112 | i1 = 0 113 | j1 = 1 114 | } 115 | // A step of (1,0) in (i,j) means a step of (1-c,-c) in (x,y), and 116 | // a step of (0,1) in (i,j) means a step of (-c,1-c) in (x,y), where 117 | // c = (3-sqrt(3))/6 118 | const x1 = x0 - i1 + G2 // Offsets for middle corner in (x,y) unskewed coords 119 | const y1 = y0 - j1 + G2 120 | const x2 = x0 - 1 + 2 * G2 // Offsets for last corner in (x,y) unskewed coords 121 | const y2 = y0 - 1 + 2 * G2 122 | // Work out the hashed gradient indices of the three simplex corners 123 | i &= 255 124 | j &= 255 125 | const gi0 = gradP[i + perm[j]] 126 | const gi1 = gradP[i + i1 + perm[j + j1]] 127 | const gi2 = gradP[i + 1 + perm[j + 1]] 128 | // Calculate the contribution from the three corners 129 | let t0 = 0.5 - x0 * x0 - y0 * y0 130 | if (t0 < 0) { 131 | n0 = 0 132 | } else { 133 | t0 *= t0 134 | n0 = t0 * t0 * gi0.dot2(x0, y0) // (x,y) of grad3 used for 2D gradient 135 | } 136 | let t1 = 0.5 - x1 * x1 - y1 * y1 137 | if (t1 < 0) { 138 | n1 = 0 139 | } else { 140 | t1 *= t1 141 | n1 = t1 * t1 * gi1.dot2(x1, y1) 142 | } 143 | let t2 = 0.5 - x2 * x2 - y2 * y2 144 | if (t2 < 0) { 145 | n2 = 0 146 | } else { 147 | t2 *= t2 148 | n2 = t2 * t2 * gi2.dot2(x2, y2) 149 | } 150 | // Add contributions from each corner to get the final noise value. 151 | // The result is scaled to return values in the interval [-1,1]. 152 | return 70 * (n0 + n1 + n2) 153 | } 154 | -------------------------------------------------------------------------------- /src/terrain.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | /// 5 | import {Vec3, Color} from './vec' 6 | 7 | // Terrain uses a custom shader so that we can apply the same 8 | // type of fog as is applied to the grass. This way they both 9 | // blend to green first, then blend to atmosphere color in the 10 | // distance. 11 | 12 | // Uses terrain shaders (see: shader/terrain.*.glsl) 13 | 14 | const MAX_INDICES = 262144 // 65536 15 | const TEX_SCALE = 1.0 / 6.0 // texture scale per quad 16 | 17 | export interface TerrainOptions { 18 | textures: THREE.Texture[] 19 | vertScript: string 20 | fragScript: string 21 | heightMap: THREE.Texture 22 | heightMapScale: Vec3 23 | fogColor: Color 24 | fogFar: number 25 | grassFogFar: number 26 | transitionLow: number 27 | transitionHigh: number 28 | } 29 | 30 | interface Terrain { 31 | cellSize: number 32 | xCellCount: number 33 | yCellCount: number 34 | xSize: number 35 | ySize: number 36 | mesh: THREE.Mesh 37 | } 38 | 39 | function Terrain (opts: TerrainOptions): Terrain { 40 | // max square x,y divisions that will fit in max indices 41 | const xCellCount = Math.floor(Math.sqrt(MAX_INDICES / (3 * 2))) 42 | const yCellCount = xCellCount 43 | const cellSize = 1.0 / opts.heightMapScale.x / xCellCount 44 | 45 | return { 46 | cellSize, 47 | xCellCount, 48 | yCellCount, 49 | xSize: xCellCount * cellSize, 50 | ySize: yCellCount * cellSize, 51 | mesh: createMesh(opts) 52 | } 53 | } 54 | 55 | namespace Terrain { 56 | export function update (t: Terrain, x: number, y: number) { 57 | const ix = Math.floor(x / t.cellSize) 58 | const iy = Math.floor(y / t.cellSize) 59 | const ox = ix * t.cellSize 60 | const oy = iy * t.cellSize 61 | const mat = t.mesh.material as THREE.RawShaderMaterial 62 | let p = mat.uniforms['offset'].value as number[] 63 | p[0] = ox 64 | p[1] = oy 65 | p = mat.uniforms['uvOffset'].value 66 | p[0] = iy * TEX_SCALE // not sure why x,y need to be swapped here... 67 | p[1] = ix * TEX_SCALE 68 | } 69 | } 70 | 71 | export default Terrain 72 | 73 | // Internal helpers... 74 | 75 | /** Creates a textured plane larger than the viewer will ever travel */ 76 | function createMesh (opts: TerrainOptions) { 77 | // max x,y divisions that will fit 65536 indices 78 | const xCellCount = Math.floor(Math.sqrt(MAX_INDICES / (3 * 2))) 79 | const yCellCount = xCellCount 80 | const cellSize = 1.0 / opts.heightMapScale.x / xCellCount 81 | const texs = opts.textures 82 | texs.forEach(tex => { 83 | tex.wrapS = tex.wrapT = THREE.RepeatWrapping 84 | tex.anisotropy = 9 85 | }) 86 | const htex = opts.heightMap 87 | htex.wrapS = htex.wrapT = THREE.RepeatWrapping 88 | const vtxBufs = createVtxBuffers(cellSize, xCellCount + 1, yCellCount + 1) 89 | const idBuf = createIdBuffer(xCellCount + 1, yCellCount + 1) 90 | const geo = new THREE.BufferGeometry() 91 | geo.addAttribute('position', new THREE.BufferAttribute(vtxBufs.position, 3)) 92 | geo.addAttribute('uv', new THREE.BufferAttribute(vtxBufs.uv, 2)) 93 | geo.setIndex(new THREE.BufferAttribute(idBuf, 1)) 94 | const hscale = opts.heightMapScale 95 | 96 | const fragScript = opts.fragScript.replace( 97 | '%%TRANSITION_LOW%%', opts.transitionLow.toString() 98 | ).replace( 99 | '%%TRANSITION_HIGH%%', opts.transitionHigh.toString() 100 | ) 101 | 102 | const mat = new THREE.RawShaderMaterial({ 103 | uniforms: { 104 | offset: {type: '2f', value: [0.0, 0.0]}, 105 | uvOffset: {type: '2f', value: [0.0, 0.0]}, 106 | map1: {type: 't', value: texs[0]}, 107 | map2: {type: 't', value: texs[1]}, 108 | heightMap: {type: 't', value: htex}, 109 | heightMapScale: {type: '3f', value: [hscale.x, hscale.y, hscale.z]}, 110 | fogColor: {type: '3f', value: Color.toArray(opts.fogColor)}, 111 | fogNear: {type: 'f', value: 1.0}, 112 | fogFar: {type: 'f', value: opts.fogFar}, 113 | grassFogFar: {type: 'f', value: opts.grassFogFar} 114 | }, 115 | vertexShader: opts.vertScript, 116 | fragmentShader: fragScript 117 | }) 118 | const mesh = new THREE.Mesh(geo, mat) 119 | mesh.frustumCulled = false 120 | return mesh 121 | } 122 | 123 | /** 124 | * @param cellSize Size of each mesh cell (quad) 125 | * @param xcount X vertex count 126 | * @param ycount Y vertex count 127 | */ 128 | function createVtxBuffers (cellSize: number, xcount: number, ycount: number) { 129 | const pos = new Float32Array(xcount * ycount * 3) 130 | const uv = new Float32Array(xcount * ycount * 2) 131 | let ix: number, iy: number 132 | let x: number, y: number 133 | let u: number, v: number 134 | let i = 0 135 | let j = 0 136 | for (iy = 0; iy < ycount; ++iy) { 137 | y = (iy - ycount / 2.0) * cellSize 138 | u = iy 139 | for (ix = 0; ix < xcount; ++ix) { 140 | x = (ix - xcount / 2.0) * cellSize 141 | v = ix 142 | pos[i++] = x 143 | pos[i++] = y 144 | pos[i++] = 4.0 * Math.cos(ix * 1.0) + 4.0 * Math.sin(iy * 1.0) 145 | uv[j++] = u * TEX_SCALE 146 | uv[j++] = v * TEX_SCALE 147 | } 148 | } 149 | return {position: pos, uv} 150 | } 151 | 152 | /** 153 | * @param xcount X vertex count 154 | * @param ycount Y vertex count 155 | */ 156 | function createIdBuffer(xcount: number, ycount: number) { 157 | const idSize = (xcount - 1) * (ycount - 1) * 3 * 2 158 | let id: Uint16Array | Uint32Array 159 | if (idSize <= 65536) { 160 | id = new Uint16Array(idSize) 161 | } else { 162 | id = new Uint32Array(idSize) 163 | } 164 | const xc = xcount - 1 165 | const yc = ycount - 1 166 | let x: number, y: number 167 | for (y = 0; y < yc; ++y) { 168 | for (x = 0; x < xc; ++x) { 169 | const i = 6 * (y * xc + x) 170 | // tri 1 171 | id[i + 0] = (y + 0) * xcount + (x + 0) 172 | id[i + 1] = (y + 0) * xcount + (x + 1) 173 | id[i + 2] = (y + 1) * xcount + (x + 1) 174 | // tri 2 175 | id[i + 3] = (y + 1) * xcount + (x + 1) 176 | id[i + 4] = (y + 1) * xcount + (x + 0) 177 | id[i + 5] = (y + 0) * xcount + (x + 0) 178 | } 179 | } 180 | return id 181 | } 182 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import {$e, $i, detectWebGL} from './util' 2 | import Loader, {Assets} from './loader' 3 | import * as input from './input' 4 | import * as anim from './anim' 5 | import * as fullscreen from './fullscreen' 6 | import * as browser from './browser' 7 | import World from './world' 8 | 9 | interface Config { 10 | blades: number 11 | depth: number 12 | antialias: boolean 13 | } 14 | 15 | // circa 2016 16 | const CONFIGS: {[id: string]: Config} = { 17 | mobile: {blades: 20000, depth: 50.0, antialias: false}, 18 | laptop: {blades: 40000, depth: 65.0, antialias: false}, 19 | desktop: {blades: 84000, depth: 85.0, antialias: true}, 20 | desktop2: {blades: 250000, depth: 125.0, antialias: true}, 21 | gamerig: {blades: 500000, depth: 175.0, antialias: true} 22 | } 23 | 24 | /** 25 | * Create App instance 26 | */ 27 | export default function App() { 28 | 29 | // DOM element containing canvas 30 | const container = $e('app_canvas_container') 31 | 32 | // Will be set correctly later 33 | let displayWidth = 640 34 | let displayHeight = 480 35 | 36 | let assets: Assets 37 | let world: World 38 | 39 | let isFullscreen = fullscreen.is() 40 | 41 | /** 42 | * Call this when HTML page elements are loaded & ready 43 | */ 44 | function run() { 45 | if (!$e('app_canvas_container')) { 46 | console.error("app_canvas_container element not found in page") 47 | return false 48 | } 49 | 50 | if (!detectWebGL()) { 51 | $e('loading_text').textContent = "WebGL unavailable." 52 | return false 53 | } 54 | 55 | resize() 56 | loadAssets() 57 | configUI() 58 | window.addEventListener('resize', resize, false) 59 | return true 60 | } 61 | 62 | /** 63 | * Configuration UI input handlers 64 | */ 65 | function configUI() { 66 | // Select a config roughly based on device type 67 | const cfgId = browser.isMobile.any ? 'mobile' : 'desktop' 68 | const cfg = CONFIGS[cfgId] 69 | const sel = $i('sel_devicepower') 70 | sel.value = cfgId 71 | const inp_blades = $i('inp_blades') 72 | inp_blades.value = cfg.blades.toString() 73 | const inp_depth = $i('inp_depth') 74 | inp_depth.value = cfg.depth.toString() 75 | $i('chk_antialias').checked = cfg.antialias 76 | $i('chk_fullscreen').checked = false 77 | $i('chk_fullscreen').onchange = () => { 78 | fullscreen.toggle($e('app_container')) 79 | } 80 | sel.onchange = (e: Event) => { 81 | const cfg = CONFIGS[sel.value] 82 | const b = cfg.blades.toString() 83 | const d = cfg.depth.toString() 84 | inp_blades.value = b 85 | inp_depth.value = d 86 | $e('txt_blades').textContent = b 87 | $e('txt_depth').textContent = d 88 | $i('chk_antialias').checked = cfg.antialias 89 | } 90 | $e('txt_blades').textContent = cfg.blades.toString() 91 | $e('txt_depth').textContent = cfg.depth.toString() 92 | inp_blades.onchange = e => { 93 | $e('txt_blades').textContent = inp_blades.value 94 | } 95 | inp_depth.onchange = e => { 96 | $e('txt_depth').textContent = inp_depth.value 97 | } 98 | } 99 | 100 | function loadAssets() { 101 | const loader = Loader() 102 | loader.load( 103 | { 104 | text: [ 105 | {name: 'grass.vert', url: 'shader/grass.vert.glsl'}, 106 | {name: 'grass.frag', url: 'shader/grass.frag.glsl'}, 107 | {name: 'terrain.vert', url: 'shader/terrain.vert.glsl'}, 108 | {name: 'terrain.frag', url: 'shader/terrain.frag.glsl'}, 109 | {name: 'water.vert', url: 'shader/water.vert.glsl'}, 110 | {name: 'water.frag', url: 'shader/water.frag.glsl'} 111 | ], 112 | images: [ 113 | {name: 'heightmap', url: 'data/heightmap.jpg'}, 114 | {name: 'noise', url: 'data/noise.jpg'} 115 | ], 116 | textures: [ 117 | {name: 'grass', url: 'data/grass.jpg'}, 118 | {name: 'terrain1', url: 'data/terrain1.jpg'}, 119 | {name: 'terrain2', url: 'data/terrain2.jpg'}, 120 | {name: 'skydome', url: 'data/skydome.jpg'}, 121 | {name: 'skyenv', url: 'data/skyenv.jpg'} 122 | ] 123 | }, 124 | onAssetsLoaded, 125 | onAssetsProgress, 126 | onAssetsError 127 | ) 128 | } 129 | 130 | function onAssetsProgress (p: number) { 131 | const pct = Math.floor(p * 90) 132 | $e('loading_bar').style.width = pct + '%' 133 | } 134 | 135 | function onAssetsError (e: string) { 136 | $e('loading_text').textContent = e 137 | } 138 | 139 | function onAssetsLoaded(a: Assets) { 140 | assets = a 141 | $e('loading_bar').style.width = '100%' 142 | $e('loading_text').innerHTML = " " 143 | setTimeout(() => { 144 | $e('loading_bar_outer').style.visibility = 'hidden' 145 | $e('config_block').style.visibility = 'visible' 146 | $e('btn_start').onclick = () => { 147 | anim.fadeOut($e('loading_block'), 80, () => { 148 | $e('loading_block').style.display = 'none' 149 | if (!isFullscreen) { 150 | $e('title_bar').style.display = 'block' 151 | } 152 | $e('btn_fullscreen').onclick = () => { 153 | fullscreen.toggle($e('app_container')) 154 | } 155 | $e('btn_restart').onclick = () => { 156 | document.location!.reload() 157 | } 158 | start() 159 | }) 160 | } 161 | }, 10) 162 | } 163 | 164 | /** 165 | * All stuff loaded, setup event handlers & start the app... 166 | */ 167 | function start() { 168 | if ($i('chk_audio').checked) { 169 | const au = $e('chopin') as HTMLAudioElement 170 | au.loop = true 171 | au.play() 172 | } 173 | input.init() 174 | // Get detail settings from UI inputs 175 | const numGrassBlades = +($i('inp_blades').value) 176 | const grassPatchRadius = +($i('inp_depth').value) 177 | const antialias = !!($i('chk_antialias').checked) 178 | // Create an instance of the world 179 | world = World( 180 | assets, numGrassBlades, grassPatchRadius, 181 | displayWidth, displayHeight, antialias 182 | ) 183 | // Start our animation loop 184 | doFrame() 185 | } 186 | 187 | function doFrame() { 188 | // keep animation loop running 189 | world.doFrame() 190 | requestAnimationFrame(doFrame) 191 | } 192 | 193 | /** Handle window resize events */ 194 | function resize() { 195 | displayWidth = container.clientWidth 196 | displayHeight = container.clientHeight 197 | if (world) { 198 | world.resize(displayWidth, displayHeight) 199 | } else { 200 | const canvas = $e('app_canvas') as HTMLCanvasElement 201 | canvas.width = displayWidth 202 | canvas.height = displayHeight 203 | } 204 | 205 | // Seems to be a good place to check for fullscreen toggle. 206 | const fs = fullscreen.is() 207 | if (fs !== isFullscreen) { 208 | // Show/hide the UI when switching windowed/FS mode. 209 | $e('title_bar').style.display = fs ? 'none' : 'block' 210 | isFullscreen = fs 211 | } 212 | } 213 | 214 | // Return public interface 215 | return { 216 | run 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /public/shader/grass.vert.glsl: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2017 by Mike Linkovich 3 | 4 | precision highp float; 5 | 6 | #define PI 3.141592654 7 | 8 | // These define values should be replaced by app before compiled 9 | #define PATCH_SIZE (%%PATCH_SIZE%%) 10 | #define BLADE_SEGS (%%BLADE_SEGS%%) // # of blade segments 11 | #define BLADE_HEIGHT_TALL (%%BLADE_HEIGHT_TALL%%) // height of a tall blade 12 | 13 | #define BLADE_DIVS (BLADE_SEGS + 1.0) // # of divisions 14 | #define BLADE_VERTS (BLADE_DIVS * 2.0) // # of vertices (per side, so 1/2 total) 15 | 16 | #define TRANSITION_LOW (%%TRANSITION_LOW%%) // elevation of beach-grass transition (start) 17 | #define TRANSITION_HIGH (%%TRANSITION_HIGH%%) // (end) 18 | #define TRANSITION_NOISE 0.06 // transition noise scale 19 | 20 | const vec3 LIGHT_COLOR = vec3(1.0, 1.0, 0.99); 21 | const vec3 SPECULAR_COLOR = vec3(1.0, 1.0, 0.0); 22 | 23 | uniform mat4 modelViewMatrix; 24 | uniform mat4 projectionMatrix; 25 | uniform vec3 lightDir; 26 | uniform vec3 camDir; // direction cam is looking at 27 | uniform vec2 drawPos; // centre of where we want to draw 28 | uniform float time; // used to animate blades 29 | uniform sampler2D heightMap; 30 | uniform vec3 heightMapScale; 31 | uniform vec3 grassColor; 32 | uniform float windIntensity; 33 | 34 | attribute float vindex; // Which vertex are we drawing - the main thing we need to know 35 | attribute vec4 offset; // {x:x, y:y, z:z, w:rot} (blade's position & rotation) 36 | attribute vec4 shape; // {x:width, y:height, z:lean, w:curve} (blade's shape properties) 37 | 38 | varying vec2 vSamplePos; 39 | varying vec4 vColor; 40 | varying vec2 vUv; 41 | 42 | // Rotate by an angle 43 | vec2 rotate (float x, float y, float r) { 44 | float c = cos(r); 45 | float s = sin(r); 46 | return vec2(x * c - y * s, x * s + y * c); 47 | } 48 | 49 | // Rotate by a vector 50 | vec2 rotate (float x, float y, vec2 r) { 51 | return vec2(x * r.x - y * r.y, x * r.y + y * r.x); 52 | } 53 | 54 | void main() { 55 | float vi = mod(vindex, BLADE_VERTS); // vertex index for this side of the blade 56 | float di = floor(vi / 2.0); // div index (0 .. BLADE_DIVS) 57 | float hpct = di / BLADE_SEGS; // percent of height of blade this vertex is at 58 | float bside = floor(vindex / BLADE_VERTS); // front/back side of blade 59 | float bedge = mod(vi, 2.0); // left/right edge (x=0 or x=1) 60 | // Vertex position - start with 2D shape, no bend applied 61 | vec3 vpos = vec3( 62 | shape.x * (bedge - 0.5) * (1.0 - pow(hpct, 3.0)), // taper blade edges as approach tip 63 | 0.0, // flat y, unbent 64 | shape.y * di / BLADE_SEGS // height of vtx, unbent 65 | ); 66 | 67 | // Start computing a normal for this vertex 68 | vec3 normal = vec3(rotate(0.0, bside * 2.0 - 1.0, offset.w), 0.0); 69 | 70 | // Apply blade's natural curve amount 71 | float curve = shape.w; 72 | // Then add animated curve amount by time using this blade's 73 | // unique properties to randomize its oscillation 74 | curve += shape.w + 0.125 * (sin(time * 4.0 + offset.w * 0.2 * shape.y + offset.x + offset.y)); 75 | // put lean and curve together 76 | float rot = shape.z + curve * hpct; 77 | vec2 rotv = vec2(cos(rot), sin(rot)); 78 | vpos.yz = rotate(vpos.y, vpos.z, rotv); 79 | normal.yz = rotate(normal.y, normal.z, rotv); 80 | 81 | // rotation of this blade as a vector 82 | rotv = vec2(cos(offset.w), sin(offset.w)); 83 | vpos.xy = rotate(vpos.x, vpos.y, rotv); 84 | 85 | // Based on centre of view cone position, what grid tile should 86 | // this piece of grass be drawn at? 87 | vec2 gridOffset = vec2( 88 | floor((drawPos.x - offset.x) / PATCH_SIZE) * PATCH_SIZE + PATCH_SIZE / 2.0, 89 | floor((drawPos.y - offset.y) / PATCH_SIZE) * PATCH_SIZE + PATCH_SIZE / 2.0 90 | ); 91 | 92 | // Find the blade mesh world x,y position 93 | vec2 bladePos = vec2(offset.xy + gridOffset); 94 | 95 | // height/light map sample position 96 | vSamplePos = bladePos.xy * heightMapScale.xy + vec2(0.5, 0.5); 97 | 98 | // Compute wind effect 99 | // Using the lighting channel as noise seems make the best looking wind for some reason! 100 | float wind = texture2D(heightMap, vec2(vSamplePos.x - time / 2500.0, vSamplePos.y - time / 200.0) * 6.0).g; 101 | //float wind = texture2D(heightMap, vec2(vSamplePos.x - time / 2500.0, vSamplePos.y - time / 100.0) * 6.0).r; 102 | //float wind = texture2D(heightMap, vec2(vSamplePos.x - time / 2500.0, vSamplePos.y - time / 100.0) * 4.0).b; 103 | // Apply some exaggeration to wind 104 | //wind = (clamp(wind, 0.125, 0.875) - 0.125) * (1.0 / 0.75); 105 | wind = (clamp(wind, 0.25, 1.0) - 0.25) * (1.0 / 0.75); 106 | wind = wind * wind * windIntensity; 107 | wind *= hpct; // scale wind by height of blade 108 | wind = -wind; 109 | rotv = vec2(cos(wind), sin(wind)); 110 | // Wind blows in axis-aligned direction to make things simpler 111 | vpos.yz = rotate(vpos.y, vpos.z, rotv); 112 | normal.yz = rotate(normal.y, normal.z, rotv); 113 | 114 | // Sample the heightfield data texture to get altitude for this blade position 115 | vec4 hdata = texture2D(heightMap, vSamplePos); 116 | float altitude = hdata.r; 117 | 118 | // Determine if we want the grass to appear or not 119 | // Use the noise channel to perturb the altitude grass starts growing at. 120 | float noisyAltitude = altitude + hdata.b * TRANSITION_NOISE - (TRANSITION_NOISE / 2.0); 121 | float degenerate = (clamp(noisyAltitude, TRANSITION_LOW, TRANSITION_HIGH) - TRANSITION_LOW) 122 | * (1.0 / (TRANSITION_HIGH - TRANSITION_LOW)); 123 | 124 | // Transition geometry toward degenerate as we approach beach altitude 125 | vpos *= degenerate; 126 | 127 | // Vertex color must be brighter because it is multiplied with blade texture 128 | vec3 color = min(vec3(grassColor.r * 1.25, grassColor.g * 1.25, grassColor.b * 0.95), 1.0); 129 | altitude *= heightMapScale.z; 130 | 131 | // Compute directional (sun) light for this vertex 132 | float diffuse = abs(dot(normal, lightDir)); // max(-dot(normal, lightDir), 0.0); 133 | float specMag = max(-dot(normal, lightDir), 0.0) * max(-dot(normal, camDir), 0.0); 134 | specMag = pow(specMag, 1.5); // * specMag * specMag; 135 | vec3 specular = specMag * SPECULAR_COLOR * 0.4; 136 | // Directional plus ambient 137 | float light = 0.35 * diffuse + 0.65; 138 | // Ambient occlusion shading - the lower vertex, the darker 139 | float heightLight = 1.0 - hpct; 140 | heightLight = heightLight * heightLight; 141 | light = max(light - heightLight * 0.5, 0.0); 142 | vColor = vec4( 143 | // Each blade is randomly colourized a bit by its position 144 | light * 0.75 + cos(offset.x * 80.0) * 0.1, 145 | light * 0.95 + sin(offset.y * 140.0) * 0.05, 146 | light * 0.95 + sin(offset.x * 99.0) * 0.05, 147 | 1.0 148 | ); 149 | vColor.rgb = vColor.rgb * LIGHT_COLOR * color; 150 | vColor.rgb = min(vColor.rgb + specular, 1.0); 151 | 152 | // grass texture coordinate for this vertex 153 | vUv = vec2(bedge, di * 2.0); 154 | 155 | // Translate to world coordinates 156 | vpos.x += bladePos.x; 157 | vpos.y += bladePos.y; 158 | vpos.z += altitude; 159 | 160 | gl_Position = projectionMatrix * modelViewMatrix * vec4(vpos, 1.0); 161 | } 162 | -------------------------------------------------------------------------------- /src/grass.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | // Creates & animates a large patch of grass to fill the foreground. 5 | // One simple blade of grass mesh is repeated many times using instanced arrays. 6 | 7 | // Uses grass shaders (see: shader/grass.*.glsl) 8 | 9 | /// 10 | import {nrand} from './gmath' 11 | import {Vec2, Vec3, Color} from './vec' 12 | import simplex from './simplex' 13 | 14 | const BLADE_SEGS = 4 // # of blade segments 15 | const BLADE_VERTS = (BLADE_SEGS + 1) * 2 // # of vertices per blade (1 side) 16 | const BLADE_INDICES = BLADE_SEGS * 12 17 | const BLADE_WIDTH = 0.15 18 | const BLADE_HEIGHT_MIN = 2.25 19 | const BLADE_HEIGHT_MAX = 3.0 20 | 21 | /** 22 | * Setup options for grass patch 23 | */ 24 | export interface Options { 25 | lightDir: Vec3 26 | numBlades: number 27 | radius: number // distance from centre of patch to edge - half the width of the square 28 | texture: THREE.Texture 29 | vertScript: string 30 | fragScript: string 31 | heightMap: THREE.Texture 32 | heightMapScale: Vec3 33 | fogColor: Color 34 | fogFar: number 35 | grassColor: Color 36 | grassFogFar: number 37 | transitionLow: number // lower bound (0-1) of transition from beach-grass 38 | transitionHigh: number // high bound 39 | windIntensity: number 40 | } 41 | 42 | /** 43 | * Creates a patch of grass mesh. 44 | */ 45 | export function createMesh (opts: Options) { 46 | // Buffers to use for instances of blade mesh 47 | const buffers = { 48 | // Tells the shader which vertex of the blade its working on. 49 | // Rather than supplying positions, they are computed from this vindex. 50 | vindex: new Float32Array(BLADE_VERTS * 2 * 1), 51 | // Shape properties of all blades 52 | shape: new Float32Array(4 * opts.numBlades), 53 | // Positon & rotation of all blades 54 | offset: new Float32Array(4 * opts.numBlades), 55 | // Indices for a blade 56 | index: new Uint16Array(BLADE_INDICES) 57 | } 58 | 59 | initBladeIndices(buffers.index, 0, BLADE_VERTS, 0) 60 | initBladeOffsetVerts(buffers.offset, opts.numBlades, opts.radius) 61 | initBladeShapeVerts(buffers.shape, opts.numBlades, buffers.offset) 62 | initBladeIndexVerts(buffers.vindex) 63 | 64 | const geo = new THREE.InstancedBufferGeometry() 65 | // Because there are no position vertices, we must create our own bounding sphere. 66 | // (Not used because we disable frustum culling) 67 | geo.boundingSphere = new THREE.Sphere( 68 | new THREE.Vector3(0,0,0), Math.sqrt(opts.radius * opts.radius * 2.0) * 10000.0 69 | ) 70 | geo.addAttribute('vindex', new THREE.BufferAttribute(buffers.vindex, 1)) 71 | geo.addAttribute('shape', new THREE.InstancedBufferAttribute(buffers.shape, 4)) 72 | geo.addAttribute('offset', new THREE.InstancedBufferAttribute(buffers.offset, 4)) 73 | geo.setIndex(new THREE.BufferAttribute(buffers.index, 1)) 74 | 75 | const tex = opts.texture 76 | tex.wrapS = tex.wrapT = THREE.RepeatWrapping 77 | const htex = opts.heightMap 78 | htex.wrapS = htex.wrapT = THREE.RepeatWrapping 79 | const hscale = opts.heightMapScale 80 | 81 | const lightDir = Vec3.clone(opts.lightDir) 82 | lightDir.z *= 0.5 83 | Vec3.normalize(lightDir, lightDir) 84 | 85 | // Fill in some constants that never change between draw calls 86 | const vertScript = opts.vertScript.replace( 87 | '%%BLADE_HEIGHT_TALL%%', (BLADE_HEIGHT_MAX * 1.5).toFixed(1) 88 | ).replace( 89 | '%%BLADE_SEGS%%', BLADE_SEGS.toFixed(1) 90 | ).replace( 91 | '%%PATCH_SIZE%%', (opts.radius * 2.0).toFixed(1) 92 | ).replace( 93 | '%%TRANSITION_LOW%%', opts.transitionLow.toString() 94 | ).replace( 95 | '%%TRANSITION_HIGH%%', opts.transitionHigh.toString() 96 | ) 97 | 98 | // Setup shader 99 | const mat = new THREE.RawShaderMaterial({ 100 | uniforms: { 101 | lightDir: {type: '3f', value: Vec3.toArray(lightDir)}, 102 | time: {type: 'f', value: 0.0}, 103 | map: {type: 't', value: tex}, 104 | heightMap: {type: 't', value: htex}, 105 | heightMapScale: {type: '3f', value: [hscale.x, hscale.y, hscale.z]}, 106 | camDir: {type: '3f', value: [1.0, 0.0, 0.0]}, 107 | drawPos: {type: '2f', value: [100.0, 0.0]}, 108 | fogColor: {type: '3f', value: Color.toArray(opts.fogColor)}, 109 | fogNear: {type: 'f', value: 1.0}, 110 | fogFar: {type: 'f', value: opts.fogFar}, 111 | grassColor: {type: '3f', value: Color.toArray(opts.grassColor)}, 112 | grassFogFar: {type: 'f', value: opts.grassFogFar}, 113 | windIntensity: {type: 'f', value: opts.windIntensity} 114 | }, 115 | vertexShader: vertScript, 116 | fragmentShader: opts.fragScript, 117 | transparent: true 118 | }) 119 | const mesh = new THREE.Mesh(geo, mat) 120 | mesh.frustumCulled = false // always draw, never cull 121 | return mesh 122 | } 123 | 124 | /** 125 | * Sets up indices for single blade mesh. 126 | * @param id array of indices 127 | * @param vc1 vertex start offset for front side of blade 128 | * @param vc2 vertex start offset for back side of blade 129 | * @param i index offset 130 | */ 131 | function initBladeIndices(id: Uint16Array, vc1: number, vc2: number, i: number) { 132 | let seg: number 133 | // blade front side 134 | for (seg = 0; seg < BLADE_SEGS; ++seg) { 135 | id[i++] = vc1 + 0 // tri 1 136 | id[i++] = vc1 + 1 137 | id[i++] = vc1 + 2 138 | id[i++] = vc1 + 2 // tri 2 139 | id[i++] = vc1 + 1 140 | id[i++] = vc1 + 3 141 | vc1 += 2 142 | } 143 | // blade back side 144 | for (seg = 0; seg < BLADE_SEGS; ++seg) { 145 | id[i++] = vc2 + 2 // tri 1 146 | id[i++] = vc2 + 1 147 | id[i++] = vc2 + 0 148 | id[i++] = vc2 + 3 // tri 2 149 | id[i++] = vc2 + 1 150 | id[i++] = vc2 + 2 151 | vc2 += 2 152 | } 153 | } 154 | 155 | /** Set up shape variations for each blade of grass */ 156 | function initBladeShapeVerts(shape: Float32Array, numBlades: number, offset: Float32Array) { 157 | let noise = 0 158 | for (let i = 0; i < numBlades; ++i) { 159 | noise = Math.abs(simplex(offset[i * 4 + 0] * 0.03, offset[i * 4 + 1] * 0.03)) 160 | noise = noise * noise * noise 161 | noise *= 5.0 162 | shape[i * 4 + 0] = BLADE_WIDTH + Math.random() * BLADE_WIDTH * 0.5 // width 163 | shape[i * 4 + 1] = BLADE_HEIGHT_MIN + Math.pow(Math.random(), 4.0) * (BLADE_HEIGHT_MAX - BLADE_HEIGHT_MIN) + // height 164 | noise 165 | shape[i * 4 + 2] = 0.0 + Math.random() * 0.3 // lean 166 | shape[i * 4 + 3] = 0.05 + Math.random() * 0.3 // curve 167 | } 168 | } 169 | 170 | /** Set up positons & rotation for each blade of grass */ 171 | function initBladeOffsetVerts(offset: Float32Array, numBlades: number, patchRadius: number) { 172 | for (let i = 0; i < numBlades; ++i) { 173 | offset[i * 4 + 0] = nrand() * patchRadius // x 174 | offset[i * 4 + 1] = nrand() * patchRadius // y 175 | offset[i * 4 + 2] = 0.0 // z 176 | offset[i * 4 + 3] = Math.PI * 2.0 * Math.random() // rot 177 | } 178 | } 179 | 180 | /** Set up indices for 1 blade */ 181 | function initBladeIndexVerts(vindex: Float32Array) { 182 | for (let i = 0; i < vindex.length; ++i) { 183 | vindex[i] = i 184 | } 185 | } 186 | 187 | /** 188 | * Call each frame to animate grass blades. 189 | * @param mesh The patch of grass mesh returned from createMesh 190 | * @param time Time in seconds 191 | * @param x X coordinate of centre position to draw at 192 | * @param y Y coord 193 | */ 194 | export function update ( 195 | mesh: THREE.Mesh, time: number, 196 | camPos: Vec3, camDir: Vec3, drawPos: Vec2 197 | ) { 198 | const mat = mesh.material as THREE.RawShaderMaterial 199 | mat.uniforms['time'].value = time 200 | let p = mat.uniforms['camDir'].value 201 | p[0] = camDir.x 202 | p[1] = camDir.y 203 | p[2] = camDir.z 204 | p = mat.uniforms['drawPos'].value 205 | p[0] = drawPos.x 206 | p[1] = drawPos.y 207 | } 208 | -------------------------------------------------------------------------------- /src/player.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 by Mike Linkovich 2 | 3 | import {clamp, sign} from './gmath' 4 | import {Vec2, Vec3} from './vec' 5 | import * as input from './input' 6 | import notify from './notification' 7 | import Heightfield from './heightfield' 8 | import * as log from './logger' 9 | 10 | const DEFAULT_HEIGHT = 0.0 11 | const MIN_HEIGHT = 2.5 12 | const MAX_HEIGHT = 275.0 13 | const FLOAT_VEL = 0.75 14 | const BOB_RANGE = 16.0 15 | const DEFAULT_PITCH = -0.325 16 | const MOVE_RANGE = 1500.0 17 | 18 | const ACCEL = 90.0 // forward accel 19 | const DRAG = 0.1 20 | const VACCEL = 60.0 // vertical accel 21 | const VDRAG = 5.0 22 | const YAW_ACCEL = 4.0 // angular accel (yaw) 23 | const YAW_DRAG = 2.0 24 | const PITCH_ACCEL = 4.0 25 | const PITCH_RESIST = 16.0 26 | const PITCH_FRIC = 8.0 27 | const ROLL_ACCEL = 2.0 28 | const ROLL_RESIST = 10.0 29 | const ROLL_FRIC = 8.0 30 | 31 | const MAN_VEL = 40.0 32 | const MAN_ZVEL = 10.0 33 | const MAN_YAWVEL = 0.5 34 | const MAN_PITCHVEL = 0.5 35 | const MAN_MAXPITCH = Math.PI / 4.0 36 | 37 | const MODE_AUTO = 0 38 | const MODE_FLY = 1 39 | const MODE_MAN = 2 40 | const NUM_MODES = 3 41 | 42 | /** Creates a Player instance (User first person camera) */ 43 | function Player (heightField: Heightfield, waterHeight: number) { 44 | 45 | //let autoplay = true 46 | let mode = MODE_AUTO 47 | let curT = 0 48 | 49 | const state = { 50 | pos: Vec3.create(0.0, 0.0, DEFAULT_HEIGHT), 51 | vel: Vec3.create(0.0, 0.0, 0.0), 52 | dir: Vec3.create(1.0, 0.0, 0.0), 53 | yaw: 0.0, 54 | yawVel: 0.0, 55 | pitch: 0.0, 56 | pitchVel: 0.0, 57 | roll: 0.0, 58 | rollVel: 0.0, 59 | floatHeight: 0.0 60 | } 61 | 62 | input.setKeyPressListener(13, function() { 63 | nextMode() 64 | if (mode === MODE_AUTO) { 65 | log.hide() 66 | notify('Press ENTER to change camera') 67 | } else if (mode === MODE_FLY) { 68 | notify('ARROWS drive, W/S move up/down.') 69 | } else if (mode === MODE_MAN) { 70 | log.show() 71 | notify('ARROWS move, W/S move up/down, Q/A look up/down') 72 | } 73 | }) 74 | 75 | // scratchpad vectors 76 | const _a = Vec3.create() 77 | const _d = Vec3.create() 78 | const _p1 = Vec3.create() 79 | const _p2 = Vec3.create() 80 | const _p3 = Vec3.create() 81 | 82 | /** 83 | * @param dt Delta time in ms 84 | */ 85 | function update(dt: number) { 86 | curT += dt 87 | // Update auto or manual 88 | if (mode === MODE_AUTO) { 89 | updateAuto(curT / 1000.0, dt) 90 | } else if (mode === MODE_FLY) { 91 | updateDrone(input.state, dt) 92 | } else if (mode === MODE_MAN) { 93 | updateManual(input.state, dt) 94 | } 95 | // Calc cam look direction vector 96 | const d = state.dir 97 | d.z = Math.sin(state.pitch) 98 | const s = (1.0 - Math.abs(d.z)) 99 | d.x = Math.cos(state.yaw) * s 100 | d.y = Math.sin(state.yaw) * s 101 | } 102 | 103 | function nextMode() { 104 | mode = (mode + 1) % NUM_MODES 105 | if (mode === MODE_MAN) { 106 | state.roll = 0 107 | state.rollVel = 0 108 | state.pitchVel = 0 109 | state.yawVel = 0 110 | } 111 | } 112 | 113 | function getMode() { 114 | return mode 115 | } 116 | 117 | /** 118 | * Update autoplay camera 119 | * @param time Time in seconds 120 | */ 121 | function updateAuto (time: number, dt: number) { 122 | const ft = dt / 1000.0 123 | 124 | // Remember last frame values 125 | Vec3.copy(state.pos, _a) 126 | const yaw0 = state.yaw 127 | const pitch0 = state.pitch 128 | 129 | // Follow a nice curvy path... 130 | //state.pos.x = Math.cos(r) * MOVE_RANGE + Math.sin(r) * MOVE_RANGE * 2.0 131 | //state.pos.y = Math.sin(r) * MOVE_RANGE + Math.cos(r) * MOVE_RANGE * 2.0 132 | autoPos(time * 0.01, state.pos) 133 | // Look ahead a few steps so we can see if there are 134 | // sudden height increases to look for 135 | autoPos((time + 1.0) * 0.01, _p1) 136 | autoPos((time + 2.0) * 0.01, _p2) 137 | autoPos((time + 3.0) * 0.01, _p3) 138 | 139 | // Move up & down smoothly 140 | const a = time * 0.3 141 | state.pos.z = BOB_RANGE + Math.cos(a) * BOB_RANGE 142 | // Look up & down depending on height 143 | state.pitch = DEFAULT_PITCH - 0.25 * Math.sin(a + Math.PI * 0.5) 144 | 145 | // Turn left & right smoothly over time 146 | state.yaw = Math.sin(time * 0.04) * Math.PI * 2.0 + Math.PI * 0.5 147 | 148 | // Actual height at camera 149 | const groundHeight = Math.max( 150 | Heightfield.heightAt(heightField, state.pos.x, state.pos.y, true), 151 | waterHeight 152 | ) 153 | // Look ahead heights 154 | const h1 = Math.max( 155 | Heightfield.heightAt(heightField, _p1.x, _p1.y, true), 156 | waterHeight 157 | ) 158 | const h2 = Math.max( 159 | Heightfield.heightAt(heightField, _p2.x, _p2.y, true), 160 | waterHeight 161 | ) 162 | const h3 = Math.max( 163 | Heightfield.heightAt(heightField, _p3.x, _p3.y, true), 164 | waterHeight 165 | ) 166 | //let minHeight = (groundHeight + h1 + h2 + h3) / 4.0 167 | const minHeight = Math.max(Math.max(Math.max(groundHeight, h1), h2), h3) 168 | let floatVel = (state.floatHeight < minHeight) ? 169 | (minHeight - state.floatHeight) : (groundHeight - state.floatHeight) 170 | if (floatVel < 0) { 171 | floatVel *= 0.25 // can sink more slowly 172 | } 173 | state.floatHeight += floatVel * FLOAT_VEL * ft 174 | // Make absolutely sure we're above ground 175 | if (state.floatHeight < groundHeight) 176 | state.floatHeight = groundHeight 177 | state.pos.z += state.floatHeight + MIN_HEIGHT 178 | 179 | // Calc velocities based on difs from prev frame 180 | _d.x = state.pos.x - _a.x 181 | _d.y = state.pos.y - _a.y 182 | _d.z = state.pos.z - _a.z 183 | state.vel.x = _d.x / ft 184 | state.vel.y = _d.y / ft 185 | state.vel.z = _d.z / ft 186 | const dyaw = state.yaw - yaw0 187 | state.yawVel = dyaw / ft 188 | const dpitch = state.pitch - pitch0 189 | state.pitchVel = dpitch / ft 190 | } 191 | 192 | function autoPos(r: number, p: Vec2) { 193 | p.x = Math.cos(r) * MOVE_RANGE + Math.sin(r) * MOVE_RANGE * 2.0 194 | p.y = Math.sin(r) * MOVE_RANGE + Math.cos(r) * MOVE_RANGE * 2.0 195 | } 196 | 197 | /** 198 | * Drone-like physics 199 | */ 200 | function updateDrone (i: input.State, dt: number) { 201 | // Delta time in seconds 202 | const ft = dt / 1000.0 203 | 204 | // calc roll accel 205 | let ra = 0 206 | if (i.left > 0) { 207 | ra = -ROLL_ACCEL 208 | } else if (i.right > 0) { 209 | ra = ROLL_ACCEL 210 | } 211 | // calc roll resist forces 212 | const rr = -state.roll * ROLL_RESIST 213 | const rf = -sign(state.rollVel) * ROLL_FRIC * Math.abs(state.rollVel) 214 | // total roll accel 215 | ra = ra + rr + rf 216 | state.rollVel += ra * ft 217 | state.roll += state.rollVel * ft 218 | 219 | // Calc yaw accel 220 | const ya = -state.roll * YAW_ACCEL 221 | // yaw drag 222 | const yd = -sign(state.yawVel) * Math.abs(Math.pow(state.yawVel, 3.0)) * YAW_DRAG 223 | // update yaw 224 | state.yawVel += (ya + yd) * ft 225 | state.yaw += state.yawVel * ft 226 | 227 | // Calc pitch accel 228 | let pa = 0 229 | if (i.forward > 0) { 230 | pa = -PITCH_ACCEL 231 | } else if (i.back > 0) { 232 | pa = PITCH_ACCEL * 0.5 233 | } 234 | // Calc pitch resist forces 235 | const pr = -state.pitch * PITCH_RESIST 236 | const pf = -sign(state.pitchVel) * PITCH_FRIC * Math.abs(state.pitchVel) 237 | // total pitch accel 238 | pa = pa + pr + pf 239 | state.pitchVel += pa * ft 240 | state.pitch += state.pitchVel * ft 241 | 242 | // Calc accel vector 243 | Vec3.set(_a, 0, 0, 0) 244 | _a.x = -state.pitch * ACCEL * Math.cos(state.yaw) 245 | _a.y = -state.pitch * ACCEL * Math.sin(state.yaw) 246 | // Calc drag vector (horizontal) 247 | const absVel = Vec2.length(state.vel) // state.vel.length() 248 | _d.x = -state.vel.x 249 | _d.y = -state.vel.y 250 | Vec2.setLength(_d, absVel * DRAG, _d) 251 | 252 | // Calc vertical accel 253 | if (i.up > 0 && state.pos.z < MAX_HEIGHT - 2.0) { 254 | _a.z = VACCEL 255 | } else if (i.down > 0 && state.pos.z > MIN_HEIGHT) { 256 | _a.z = -VACCEL 257 | } 258 | _d.z = -state.vel.z * VDRAG 259 | 260 | // update vel 261 | state.vel.x += (_a.x + _d.x) * ft 262 | state.vel.y += (_a.y + _d.y) * ft 263 | state.vel.z += (_a.z + _d.z) * ft 264 | // update pos 265 | state.pos.x += state.vel.x * ft 266 | state.pos.y += state.vel.y * ft 267 | state.pos.z += state.vel.z * ft 268 | const groundHeight = Math.max( 269 | Heightfield.heightAt(heightField, state.pos.x, state.pos.y, true), 270 | waterHeight 271 | ) 272 | if (state.pos.z < groundHeight + MIN_HEIGHT) { 273 | state.pos.z = groundHeight + MIN_HEIGHT 274 | } else if (state.pos.z > MAX_HEIGHT) { 275 | state.pos.z = MAX_HEIGHT 276 | } 277 | } 278 | 279 | /** 280 | * Manual movement 281 | */ 282 | function updateManual (i: input.State, dt: number) { 283 | const ft = dt / 1000.0 284 | 285 | state.yawVel = 0 286 | if (i.left) { 287 | state.yawVel = MAN_YAWVEL 288 | } else if (i.right) { 289 | state.yawVel = -MAN_YAWVEL 290 | } 291 | state.yaw += state.yawVel * ft 292 | 293 | state.pitchVel = 0 294 | if (i.pitchup) { 295 | state.pitchVel = MAN_PITCHVEL 296 | } else if (i.pitchdown) { 297 | state.pitchVel = -MAN_PITCHVEL 298 | } 299 | state.pitch += state.pitchVel * ft 300 | state.pitch = clamp(state.pitch, -MAN_MAXPITCH, MAN_MAXPITCH) 301 | 302 | Vec3.set(state.vel, 0, 0, 0) 303 | if (i.forward) { 304 | state.vel.x = MAN_VEL * Math.cos(state.yaw) 305 | state.vel.y = MAN_VEL * Math.sin(state.yaw) 306 | } else if (i.back) { 307 | state.vel.x = -MAN_VEL * Math.cos(state.yaw) 308 | state.vel.y = -MAN_VEL * Math.sin(state.yaw) 309 | } 310 | state.pos.x += state.vel.x * ft 311 | state.pos.y += state.vel.y * ft 312 | 313 | if (i.up) { 314 | state.vel.z = MAN_ZVEL 315 | } else if (i.down) { 316 | state.vel.z = -MAN_ZVEL 317 | } 318 | state.pos.z += state.vel.z * ft 319 | 320 | const groundHeight = Math.max( 321 | Heightfield.heightAt(heightField, state.pos.x, state.pos.y, true), 322 | waterHeight 323 | ) 324 | if (state.pos.z < groundHeight + MIN_HEIGHT) { 325 | state.pos.z = groundHeight + MIN_HEIGHT 326 | } else if (state.pos.z > MAX_HEIGHT) { 327 | state.pos.z = MAX_HEIGHT 328 | } 329 | } 330 | 331 | /** 332 | * Public interface 333 | */ 334 | return { 335 | update, 336 | state, 337 | getMode, 338 | nextMode 339 | } 340 | } 341 | 342 | interface Player extends ReturnType {} 343 | 344 | export default Player 345 | -------------------------------------------------------------------------------- /src/heightfield.ts: -------------------------------------------------------------------------------- 1 | // LICENSE: MIT 2 | // Copyright (c) 2016 by Mike Linkovich 3 | 4 | import {pmod} from './gmath' 5 | import {Vec3} from './vec' 6 | 7 | /** 8 | * Info returned when querying heightfield at coordinate X,Y 9 | */ 10 | export interface HInfo { 11 | /** cell (quad) index */ 12 | i: number 13 | /** triangle (0 top-left or 1 bottom-right) */ 14 | t: number 15 | /** height */ 16 | z: number 17 | /** normal */ 18 | n: Vec3 19 | } 20 | 21 | export function HInfo(): HInfo { 22 | return { 23 | i: 0, t: 0, z: 0.0, n: Vec3.create() 24 | } 25 | } 26 | 27 | export interface HeightfieldOptions { 28 | cellSize?: number 29 | minHeight?: number 30 | maxHeight?: number 31 | xCount?: number 32 | yCount?: number 33 | heights?: ArrayLike // supply counts & heights array OR image 34 | image?: HTMLImageElement 35 | } 36 | 37 | /////////////////////////////////////////////////////////// 38 | /** 39 | * Heightfield 40 | * 41 | * Cartesian layout of quads as 2 tris: 42 | * ____ 43 | * |0/| 44 | * |/_|1 45 | * 46 | */ 47 | interface Heightfield { 48 | cellSize: number 49 | minHeight: number 50 | maxHeight: number 51 | xCount: number 52 | yCount: number 53 | xSize: number 54 | ySize: number 55 | heights: ArrayLike 56 | faceNormals: Float32Array 57 | vtxNormals: Float32Array 58 | } 59 | 60 | /** 61 | * Create a Heightfield using the given options. 62 | * Use either an image OR xCount, yCount and a heights array. 63 | */ 64 | function Heightfield (info: HeightfieldOptions): Heightfield { 65 | const hf: Heightfield = { 66 | cellSize: (info.cellSize && info.cellSize > 0) ? info.cellSize : 1.0, 67 | minHeight: (typeof info.minHeight === 'number') ? info.minHeight : 0.0, 68 | maxHeight: (typeof info.maxHeight === 'number') ? info.maxHeight : 1.0, 69 | xCount: 0, // remaining will be computed later 70 | yCount: 0, 71 | xSize: 0, 72 | ySize: 0, 73 | heights: new Float32Array(0), 74 | faceNormals: new Float32Array(0), // packed: [x0, y0, z0, x1, y1, z1] 75 | vtxNormals: new Float32Array(0) 76 | } 77 | 78 | if (info.image) { 79 | genFromImg(info.image, hf) 80 | } 81 | else { 82 | hf.xCount = info.xCount && info.xCount > 0 ? Math.floor(info.xCount) : 1 83 | hf.yCount = info.yCount && info.yCount > 0 ? Math.floor(info.yCount) : 1 84 | hf.xSize = hf.xCount * hf.cellSize 85 | hf.ySize = info.yCount! * hf.cellSize 86 | hf.heights = info.heights || new Float32Array((hf.xCount + 1) * (hf.yCount + 1)) 87 | // 2 normals per cell (quad) 88 | hf.faceNormals = new Float32Array(3 * 2 * hf.xCount * hf.yCount) 89 | hf.vtxNormals = new Float32Array(3 * (hf.xCount + 1) * (hf.yCount + 1)) 90 | calcFaceNormals(hf) 91 | } 92 | 93 | return hf 94 | } 95 | 96 | namespace Heightfield { 97 | /** 98 | * Get heightfield info at point x,y. Outputs to hi. 99 | * @param wrap If true, x,y coords will be wrapped around if out of bounds, 100 | * otherwise minHeight returned. 101 | * @param hi Struct to output result into. 102 | */ 103 | export function infoAt (hf: Heightfield, x: number, y: number, wrap: boolean, hi: HInfo) { 104 | const ox = -(hf.xSize / 2.0) // bottom left of heightfield 105 | const oy = -(hf.ySize / 2.0) 106 | 107 | if (x < ox || x >= -ox || y < oy || y >= -oy) { 108 | if (!wrap) { 109 | // out of bounds 110 | hi.i = -1 111 | hi.z = hf.minHeight 112 | hi.n.x = hi.n.y = hi.n.z = 0 113 | hi.t = 0 114 | return 115 | } 116 | // wrap around 117 | x = pmod(x - ox, hf.xSize) + ox 118 | y = pmod(y - oy, hf.ySize) + oy 119 | } 120 | 121 | const csz = hf.cellSize, 122 | normals = hf.faceNormals, 123 | n = hi.n, 124 | ix = Math.floor((x - ox) / csz), 125 | iy = Math.floor((y - oy) / csz), 126 | ih = ix + iy * (hf.xCount + 1), // height index 127 | px = (x - ox) % csz, // relative x,y within this quad 128 | py = (y - oy) % csz 129 | 130 | const i = ix + iy * hf.xCount // tile index 131 | 132 | if (py > 0 && px / py < 1.0) { 133 | // top left tri 134 | hi.t = 0 135 | n.x = normals[i * 6 + 0] 136 | n.y = normals[i * 6 + 1] 137 | n.z = normals[i * 6 + 2] 138 | } 139 | else { 140 | // bottom right tri 141 | hi.t = 1 142 | n.x = normals[i * 6 + 3] 143 | n.y = normals[i * 6 + 4] 144 | n.z = normals[i * 6 + 5] 145 | } 146 | hi.i = i 147 | hi.z = getPlaneZ(n, hf.heights[ih], px, py) 148 | } 149 | 150 | // pre-allocated scratchpad object 151 | const _hi = HInfo() 152 | 153 | /** 154 | * Get height (z) at x,y 155 | * @param wrap If true, x,y coords will be wrapped around if out of bounds, 156 | * otherwise minHeight returned. 157 | */ 158 | export function heightAt (hf: Heightfield, x: number, y: number, wrap = false) { 159 | infoAt(hf, x, y, wrap, _hi) 160 | return _hi.z 161 | } 162 | 163 | /** 164 | * Given a plane with normal n and z=z0 at (x=0,y=0) find z at x,y. 165 | * @param n Normal vector of the plane. 166 | * @param z0 Height (z) coordinate of the plane at x=0,y=0. 167 | * @param x X coordinate to find height (z) at. 168 | * @param y Y coordinate to find height (z) at. 169 | */ 170 | export function getPlaneZ (n: Vec3, z0: number, x: number, y: number) { 171 | return z0 - (n.x * x + n.y * y) / n.z 172 | } 173 | } 174 | 175 | export default Heightfield 176 | 177 | // Internal helpers... 178 | 179 | /** 180 | * Generate heightfield from bitmap data. Lighter pixel colours are higher. 181 | */ 182 | function genFromImg ( 183 | image: HTMLImageElement, hf: Heightfield 184 | ) { 185 | let x: number, y: number, i: number, height: number 186 | const w = image.width, 187 | h = image.height, 188 | heightRange = hf.maxHeight - hf.minHeight 189 | 190 | hf.xCount = w - 1 191 | hf.yCount = h - 1 192 | hf.xSize = hf.xCount * hf.cellSize 193 | hf.ySize = hf.yCount * hf.cellSize 194 | 195 | // Draw to a canvas so we can get the data 196 | let canvas = document.createElement('canvas') 197 | canvas.width = w 198 | canvas.height = h 199 | let ctx = canvas.getContext('2d')! 200 | ctx.drawImage(image, 0, 0, w, h) 201 | // array of canvas pixel data [r,g,b,a, r,g,b,a, ...] 202 | let data = ctx.getImageData(0, 0, w, h).data 203 | const heights = new Float32Array(w * h) 204 | for (y = 0; y < h; ++y) { 205 | for (x = 0; x < w; ++x) { 206 | // flip vertical because textures are Y+ 207 | i = (x + (h - y - 1) * w) * 4 208 | //i = (x + y * w) * 4 209 | 210 | // normalized altitude value (0-1) 211 | // assume image is grayscale, so we only need 1 color component 212 | height = data[i] / 255.0 213 | //height = (data[i+0] + data[i+1] + data[i+2]) / (255+255+255) 214 | 215 | // scale & store this altitude 216 | heights[x + y * w] = hf.minHeight + height * heightRange 217 | } 218 | } 219 | // Free these resources soon as possible 220 | data = ctx = canvas = null as any 221 | 222 | hf.heights = heights 223 | 224 | // 2 normals per cell (quad) 225 | hf.faceNormals = new Float32Array(3 * 2 * hf.xCount * hf.yCount) 226 | hf.vtxNormals = new Float32Array(3 * (hf.xCount + 1) * (hf.yCount + 1)) 227 | calcFaceNormals(hf) 228 | calcVertexNormals(hf) 229 | } 230 | 231 | /** 232 | * Calculate normals. 233 | * 2 face normals per quad (1 per tri) 234 | */ 235 | function calcFaceNormals (hf: Heightfield) { 236 | const csz = hf.cellSize, 237 | xc = hf.xCount, // tile X & Y counts 238 | yc = hf.yCount, 239 | hxc = hf.xCount + 1, // height X count (1 larger than tile count) 240 | heights = hf.heights, // 1 less indirection 241 | normals = hf.faceNormals, 242 | v0 = Vec3.create(), 243 | v1 = Vec3.create(), 244 | n = Vec3.create() // used to compute normals 245 | let i = 0 246 | 247 | const tStart = Date.now() 248 | for (let iy = 0; iy < yc; ++iy) { 249 | for (let ix = 0; ix < xc; ++ix) { 250 | i = 6 * (ix + iy * xc) 251 | const ih = ix + iy * hxc 252 | const z = heights[ih] 253 | 254 | // 2 vectors of top-left tri 255 | v0.x = csz 256 | v0.y = csz 257 | v0.z = heights[ih + hxc + 1] - z 258 | 259 | v1.x = 0.0 260 | v1.y = csz 261 | v1.z = heights[ih + hxc] - z 262 | 263 | Vec3.cross(v0, v1, n) 264 | Vec3.normalize(n, n) 265 | normals[i + 0] = n.x 266 | normals[i + 1] = n.y 267 | normals[i + 2] = n.z 268 | 269 | // 2 vectors of bottom-right tri 270 | v0.x = csz 271 | v0.y = 0.0 272 | v0.z = heights[ih + 1] - z 273 | 274 | v1.x = csz 275 | v1.y = csz 276 | v1.z = heights[ih + hxc + 1] - z 277 | 278 | Vec3.cross(v0, v1, n) 279 | Vec3.normalize(n, n) 280 | normals[i + 3] = n.x 281 | normals[i + 4] = n.y 282 | normals[i + 5] = n.z 283 | } 284 | } 285 | const dt = Date.now() - tStart 286 | console.log(`computed ${i} heightfield face normals in ${dt}ms`) 287 | } 288 | 289 | function calcVertexNormals(hf: Heightfield) { 290 | const vnorms = hf.vtxNormals 291 | const w = hf.xCount + 1 292 | const h = hf.yCount + 1 293 | const n = Vec3.create() 294 | let i = 0 295 | const tStart = Date.now() 296 | for (let y = 0; y < h; ++y) { 297 | for (let x = 0; x < w; ++x) { 298 | computeVertexNormal(hf, x, y, n) 299 | i = (y * w + x) * 3 300 | vnorms[i++] = n.x 301 | vnorms[i++] = n.y 302 | vnorms[i++] = n.z 303 | } 304 | } 305 | const dt = Date.now() - tStart 306 | console.log(`computed ${w * h} vertex normals in ${dt}ms`) 307 | } 308 | 309 | /** 310 | * Compute a vertex normal by averaging the adjacent face normals. 311 | */ 312 | function computeVertexNormal(hf: Heightfield, vx: number, vy: number, n: Vec3) { 313 | const fnorms = hf.faceNormals 314 | // This vertex is belongs to 4 quads 315 | // Do the faces this vertex is the 1st point of for this quad. 316 | // This is the quad up and to the right 317 | let qx = vx % hf.xCount 318 | let qy = vy % hf.yCount 319 | let ni = (qy * hf.xCount + qx) * 3 * 2 320 | n.x = fnorms[ni + 0] 321 | n.y = fnorms[ni + 1] 322 | n.z = fnorms[ni + 2] 323 | ni += 3 324 | n.x += fnorms[ni + 0] 325 | n.y += fnorms[ni + 1] 326 | n.z += fnorms[ni + 2] 327 | 328 | // 2nd tri of quad up and to the left 329 | qx = pmod(qx - 1, hf.xCount) 330 | ni = (qy * hf.xCount + qx) * 3 * 2 + 3 331 | n.x += fnorms[ni + 0] 332 | n.y += fnorms[ni + 1] 333 | n.z += fnorms[ni + 2] 334 | 335 | // both tris of quad down and to the left 336 | qy = pmod(qy - 1, hf.yCount) 337 | ni = (qy * hf.xCount + qx) * 3 * 2 338 | n.x += fnorms[ni + 0] 339 | n.y += fnorms[ni + 1] 340 | n.z += fnorms[ni + 2] 341 | ni += 3 342 | n.x += fnorms[ni + 0] 343 | n.y += fnorms[ni + 1] 344 | n.z += fnorms[ni + 2] 345 | 346 | // 1st tri of quad down and to the right 347 | qx = (qx + 1) % hf.xCount 348 | ni = (qy * hf.xCount + qx) * 3 * 2 349 | n.x += fnorms[ni + 0] 350 | n.y += fnorms[ni + 1] 351 | n.z += fnorms[ni + 2] 352 | 353 | // Normalize to 'average' the result normal 354 | Vec3.normalize(n, n) 355 | } 356 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ## Terra 2 | 3 | © 2017-2020 by Mike Linkovich • spacejack.github.io 4 | 5 | --- 6 | 7 | ## [Run the Demo](https://spacejack.github.io/terra/) 8 | 9 | ![screenshot](screenshots/intro.jpg?raw=true) 10 | 11 | This is a followup to a [previous experiment](https://github.com/spacejack/poaceae) to render grass as geometry with a vertex shader. This demo adds terrain elevation, improved lighting, grass animation, wind, water and other details. 12 | 13 | Additionally, this project has been updated since its initial release. It now features terrain texturing, transitions between terrain types, more grass lighting improvements, better wind animation, a few minor fixes and a better out-of-the-box build with browserify and TypeScript 2.1. And finally this readme has been re-written to provide a more detailed description of the implementation. 14 | 15 | # Implementation 16 | 17 | ## Grass 18 | 19 | The writeup for how grass instances are rendered and placed within the view frustum was included in my [initial experiment](https://github.com/spacejack/poaceae/blob/master/readme.md) and that technique remains essentially the same in this implementation. 20 | 21 | #### 1. Geometry 22 | 23 | In this version, the grass "lean" and "curve" shapes were done more correctly using trig functions to angle the blade and curve it toward the tip. 24 | 25 | I also wanted more organic height variation for the grass. To do this I used simplex noise when the blade variations are initially generated (by the CPU) to give the grass heights a more 'clumpy' look. 26 | 27 | #### 2. Lighting 28 | 29 | Directional lighting is done dynamically in this version by computing a normal for each grass blade vertex at its current orientation rather than a crude approximation from the general orientation of the blade. A simple form of ambient occlusion is also applied - the lower the vertex on the blade, the less light it receives. 30 | 31 | My initial inclination was to perform typical diffuse lighting with a lot of ambient light on the shaded side of the blade. However this does not account for the very translucent nature of blades of grass. So rather than clamping `normal • light` to 0-1, I used the absolute value (slightly reduced and tinted yellow when the sign was negative.) This means that blades are actually darkest when perpendicular to the light source. 32 | 33 | As a finishing touch, to give the lighting a bit more pop, I used a small amount of specular highlighting when the blade was reflecting the sun into the camera. The higher the specular value, the more the grass will look like shimmering gold. 34 | 35 | ![too specular](screenshots/too-specular.jpg?raw=true) 36 | 37 | *Oops, too much specular.* 38 | 39 | And finally, because the terrain has light and shadow, that same lighting needed to be applied to the grass. This was easy enough to do by sampling the same lightmap (see Terrain Heightmap section below) used by the terrain mesh and multiplying it by the lighting computed for the grass blade. 40 | 41 | #### 3. Animation 42 | 43 | The original demo had a small amount of regular oscillation applied to each blade of grass (offset by using the x and y coordinates of the blade.) 44 | 45 | That oscillation remains but a larger wind effect was also added, coming from a consistent direction. I experimented with a number of things to get wind. Using variations and combinations of sin & cos can achieve some nice dramatic flowing effects, however when viewed up close it just looks too smooth and regular. As a source of more irregular, organic motion, I tried creating a variety of noise textures to sample, stored in the B channel of the heightmap texture. Ironically, at one point I accidentally used the G channel of the heightmap which was the light map rather than B which was intended for wind noise, and this gave me the best result! 46 | 47 | #### 4. Fading into the background 48 | 49 | My original solution to fading out the back edge of the grass patch was to apply a background-coloured fog. This time however the terrain has a texture and this approach of fading to a solid colour would not work. Instead alpha blending is used to fade out blades as they approach the draw distance limit. This actually looks better than my original approach which had much more noticable pop-in when silhouetted against the sky. 50 | 51 | One problem remained with this approach however - when viewing grass geometry up-close, you can inevitably see through to the ground. I wanted this to be a dirt-like colour, however in the distance it needed to be green (representing blades of grass in the distance.) To solive this, I compute a "foreground" colour of the ground texture by converting to grayscale, then colourizing it to a "dirt" colour. The dirt colour and the original colour of the grass texture are mixed based on distance from the camera. The closer to the camera, the more dirt, the further away, the more grass-like colour. This ends up working pretty well. 52 | 53 | ![dirt transition](screenshots/dirt-transition.jpg?raw=true) 54 | 55 | *Shows fade-to-dirt effect without grass geometry.* 56 | 57 | This effect should not be applied when rendering non-grass texture types however, so it is multiplied by the "grass amount" factor to reduce it to 0 when not wanted. (See Terrain Types and Transitions below.) 58 | 59 | ## Terrain Heightmap 60 | 61 | An easy and efficient way to render a large area of terrain as a mesh is to simply load a heightmap bitmap into video memory as a texture. This texture can be sampled for height (and other) data. The terrain geometry can be a flat grid mesh that moves with the camera, using height values from the texture to set the height component of each vertex. This eliminates the need to update any geometry with the CPU and re-upload it to video memory. 62 | 63 | ![heightmap diagram](screenshots/heightmap-diagram.jpg?raw=true) 64 | 65 | *The vertex shader samples values from the height map at vertex X,Y coordinates to get elevation Z. The terrain mesh moves with the camera.* 66 | 67 | Similarly when rendering grass blades, that same height data can be sampled to adjust the elevation of each blade. 68 | 69 | Heights alone aren't enough to make a good looking terrain though, we also need light and shadows. Since we're dealing with static sunlight only, we can pre-compute all of the lighting. In order to do that, we need to compute a normal for each pixel in the heightfield. 70 | 71 | Getting data out of the heightfield bitmap is pretty straightforward in Javascript, taking advantage of the browser image element and using a canvas 2D context to read pixel values. From those we can get mesh faces (quads split into 2 traingles each,) from those faces we can compute face normals, and from those we can compute vertex normals. 72 | 73 | `heightfield.ts` has a fairly straightforward implementation. I haven't found a heightmap Javascript implementation that I liked, so I rolled my own using `Float32Array` to store data contiguously. 74 | 75 | Smooth directional lighting can now be computed from the normals, however to make a terrain more convincing, we also want to cast shadows. `terramap.ts` uses the height and normal data from the heightfield to cast rays from each coordinate back toward the sun direction, checking to see if the ray is blocked. It also fades from dark to light as it approaches the top of the blocking shape. This cuts down on the bitmappy look of the shadows. 76 | 77 | Even on mobile, for a 256x256 heightfield, plain old single-threaded Javascript crunches the numbers admirably fast. 78 | 79 | Now we have a bitmap containing height values in the R channel and light/shadow values in the G channel. 80 | 81 | I also wanted a "noise" texture. So the grayscale file `noise.jpg` is loaded and then stored in the B channel. 82 | 83 | The alpha channel remains unused for this demo, however the extra channel could be used for higher-resolution data, terrain texture types, etc. (Note that alpha must be non-zero, otherwise some browsers will turn all the channels black for pixels with 0 alpha!) 84 | 85 | This heightfield data is uploaded to video memory as a texture which can be sampled by the grass and terrain shaders. 86 | 87 | For an in-depth article on terrain rendering with more advanced techniques for landscape detail, levels of detail by distance and texture types, see Jasmine Kent's [Gamasutra article](https://www.gamasutra.com/blogs/JasmineKent/20130904/199521/WebGL_Terrain_Rendering_in_Trigger_Rally__Part_1.php). 88 | 89 | #### Terrain textures and transitions 90 | 91 | Usually a terrain will need to have more than just one texture type. In this demo I wanted to have a sandy beach-like texture near the water's edge and grass at higher elevations. 92 | 93 | Determining which texture type to use is pretty simple here - if the elevation of the current position is above or below a certain point (just above water level.) 94 | 95 | Ideally we'd like to fade this transition since a hard edge doesn't look too great. This can be done by selecting an elevation range to transition between. The blend factor can be computed like this: 96 | 97 | float fadeAmount = (clamp(elevation, MIN_ELEVATION, MAX_ELEVATION) - MIN_ELEVATION) 98 | * (1.0 / (MAX_ELEVATION - MIN_ELEVATION)); 99 | 100 | This is nice and smooth, but very regular. To improve on this transition and make it look more organinc, we can perturb the elevation with some noise factor based on our X and Y coordinates. Earlier I had planned to create a channel for wind, however found that the lighting channel worked really well instead. So I used this free channel for a transition noise texture. Since we're already sampling the elevation at this point, the noise comes for free (as opposed to creating an expensive noise function, or performing another texture sample.) 101 | 102 | This same elevation-with-noise transition can be applied when rendering the grass. As the elevation approaches the sand, the grass geometry scale is reduced to zero (at which point the geometry is degenerate and doesn't render.) Once again, the grass vertex shader already needs to sample for elevation and can also get the noise value for free. 103 | 104 | ![terrain transition](screenshots/terrain-transition.jpg?raw=true) 105 | 106 | *Using blending and noise gives us a better beach-to-grass transition.* 107 | 108 | ## Sky 109 | 110 | I didn't discuss the skydome in the previous demo article, but I'll add a few words about it. three.js has a built-in skybox which can also be used to easily create reflections. 111 | 112 | I wanted to keep things optimal as possible for lower end hardware, and because three.js uses 6 textures for a skybox (half of which would be obscured by the ground plane) I opted for something a little less resource hungry. 113 | 114 | This skydome implementation only renders the top half of a sphere and uses just one panoramic skydome texture mapped on to that sphere. 115 | 116 | ### Water 117 | 118 | Because I was using my own skydome, I couldn't rely on three.js for reflections. As it turns out, reflecting a skydome at water level is pretty easy - simply cast a ray from the camera location down to where it would strike an inverted dome beneath the surface. Convert to a texture coordinate and that's the reflected pixel colour. I added a small amount of ripple effect, but much more could be done here with a water shader. 119 | 120 | Reflecting the terrain and grass in the water efficiently would not be easy without re-rendering everything. In any case, the focus of the demo is grass and I didn't want to spend too much time (yet) on other effects. 121 | 122 | --- 123 | 124 | ## Install 125 | 126 | npm install 127 | 128 | ## Start localhost server, compile-on-save 129 | 130 | npm start 131 | 132 | Then go to http://localhost:3000 in your browser 133 | 134 | ## Build minified 135 | 136 | npm run build 137 | 138 | Outputs terra.js in `public/js`. 139 | 140 | --- 141 | 142 | ## License 143 | 144 | This work is licensed under a Creative Commons Attribution-NonCommercial 4.0 International License: 145 | 146 | https://creativecommons.org/licenses/by-nc/4.0/ 147 | 148 | Individual sources where indicated are licensed MIT 149 | 150 | --- 151 | 152 | ## Credits 153 | 154 | This demo uses the awesome [three.js](https://threejs.org/) library. 155 | 156 | Simplex noise by Stefan Gustavson and Joseph Gentle. 157 | 158 | Nocturne in D flat major, Op. 27 no. 2 by Frédéric Chopin, performed by Frank Levy. Public domain recording from [musicopen.org](https://musopen.org/music/302/frederic-chopin/nocturnes-op-27/). 159 | -------------------------------------------------------------------------------- /src/world.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2016 by Mike Linkovich 2 | 3 | /// 4 | import {$e} from './util' 5 | import {difAngle} from './gmath' 6 | import {Vec2, Vec3, Color} from './vec' 7 | import * as logger from './logger' 8 | import * as input from './input' 9 | import {Assets} from './loader' 10 | import * as skydome from './skydome' 11 | import Heightfield, {HInfo} from './heightfield' 12 | import * as grass from './grass' 13 | import Terrain from './terrain' 14 | import * as terramap from './terramap' 15 | import * as water from './water' 16 | import Player from './player' 17 | import FPSMonitor from './fps' 18 | 19 | const VIEW_DEPTH = 2000.0 20 | 21 | const MAX_TIMESTEP = 67 // max 67 ms/frame 22 | 23 | const HEIGHTFIELD_SIZE = 3072.0 24 | const HEIGHTFIELD_HEIGHT = 180.0 25 | const WATER_LEVEL = HEIGHTFIELD_HEIGHT * 0.305556 // 55.0 26 | const BEACH_TRANSITION_LOW = 0.31 27 | const BEACH_TRANSITION_HIGH = 0.36 28 | 29 | const LIGHT_DIR = Vec3.create(0.0, 1.0, -1.0) 30 | Vec3.normalize(LIGHT_DIR, LIGHT_DIR) 31 | 32 | const FOG_COLOR = Color.create(0.74, 0.77, 0.91) 33 | const GRASS_COLOR = Color.create(0.45, 0.46, 0.19) 34 | 35 | const WATER_COLOR = Color.create(0.6, 0.7, 0.85) 36 | 37 | const WIND_DEFAULT = 1.5 38 | const WIND_MAX = 3.0 39 | 40 | const MAX_GLARE = 0.25 // max glare effect amount 41 | const GLARE_RANGE = 1.1 // angular range of effect 42 | const GLARE_YAW = Math.PI * 1.5 // yaw angle when looking directly at sun 43 | const GLARE_PITCH = 0.2 // pitch angle looking at sun 44 | const GLARE_COLOR = Color.create(1.0, 0.8, 0.4) 45 | 46 | const INTRO_FADE_DUR = 2000 47 | 48 | interface MeshSet { 49 | terrain: THREE.Mesh 50 | grass: THREE.Mesh 51 | sky: THREE.Mesh 52 | water: THREE.Mesh 53 | sunFlare: THREE.Mesh 54 | fade: THREE.Mesh // used for intro fade from white 55 | } 56 | 57 | interface World { 58 | doFrame() : void 59 | resize(w: number, h: number) : void 60 | } 61 | 62 | /////////////////////////////////////////////////////////////////////// 63 | /** 64 | * Create a World instance 65 | */ 66 | function World ( 67 | assets: Assets, 68 | numGrassBlades: number, grassPatchRadius: number, 69 | displayWidth: number, displayHeight: number, 70 | antialias: boolean 71 | ): World { 72 | 73 | const canvas = $e('app_canvas') as HTMLCanvasElement 74 | 75 | // Make canvas transparent so it isn't rendered as black for 1 frame at startup 76 | const renderer = new THREE.WebGLRenderer({ 77 | canvas, antialias, clearColor: 0xFFFFFF, clearAlpha: 1, alpha: true 78 | }) 79 | if (!renderer) { 80 | throw new Error("Failed to create THREE.WebGLRenderer") 81 | } 82 | 83 | // Setup some render values based on provided configs 84 | const fogDist = grassPatchRadius * 20.0 85 | const grassFogDist = grassPatchRadius * 2.0 86 | const camera = new THREE.PerspectiveCamera( 87 | 45, displayWidth / displayHeight, 1.0, VIEW_DEPTH 88 | ) 89 | const meshes: MeshSet = { 90 | terrain: null, grass: null, sky: null, water: null, sunFlare: null, fade: null 91 | } as any 92 | 93 | const scene = new THREE.Scene() 94 | scene.fog = new THREE.Fog(Color.to24bit(FOG_COLOR), 0.1, fogDist) 95 | 96 | // Setup the camera so Z is up. 97 | // Then we have cartesian X,Y coordinates along ground plane. 98 | camera.rotation.order = "ZXY" 99 | camera.rotation.x = Math.PI * 0.5 100 | camera.rotation.y = Math.PI * 0.5 101 | camera.rotation.z = Math.PI 102 | camera.up.set(0.0, 0.0, 1.0) 103 | 104 | // Put camera in an object so we can transform it normally 105 | const camHolder = new THREE.Object3D() 106 | camHolder.rotation.order = "ZYX" 107 | camHolder.add(camera) 108 | 109 | scene.add(camHolder) 110 | 111 | // Setup heightfield 112 | let hfImg: HTMLImageElement | undefined = assets.images['heightmap'] 113 | const hfCellSize = HEIGHTFIELD_SIZE / hfImg.width 114 | const heightMapScale = Vec3.create( 115 | 1.0 / HEIGHTFIELD_SIZE, 116 | 1.0 / HEIGHTFIELD_SIZE, 117 | HEIGHTFIELD_HEIGHT 118 | ) 119 | const heightField = Heightfield({ 120 | cellSize: hfCellSize, 121 | minHeight: 0.0, 122 | maxHeight: heightMapScale.z, 123 | image: hfImg 124 | }) 125 | hfImg = undefined 126 | 127 | const terraMap = terramap.createTexture(heightField, LIGHT_DIR, assets.images['noise']) 128 | 129 | let windIntensity = WIND_DEFAULT 130 | 131 | // Create a large patch of grass to fill the foreground 132 | meshes.grass = grass.createMesh({ 133 | lightDir: LIGHT_DIR, 134 | numBlades: numGrassBlades, 135 | radius: grassPatchRadius, 136 | texture: assets.textures['grass'], 137 | vertScript: assets.text['grass.vert'], 138 | fragScript: assets.text['grass.frag'], 139 | heightMap: terraMap, 140 | heightMapScale, 141 | fogColor: FOG_COLOR, 142 | fogFar: fogDist, 143 | grassFogFar: grassFogDist, 144 | grassColor: GRASS_COLOR, 145 | transitionLow: BEACH_TRANSITION_LOW, 146 | transitionHigh: BEACH_TRANSITION_HIGH, 147 | windIntensity 148 | }) 149 | // Set a specific render order - don't let three.js sort things for us. 150 | meshes.grass.renderOrder = 10 151 | scene.add(meshes.grass) 152 | 153 | // Terrain mesh 154 | const terra = Terrain({ 155 | textures: [assets.textures['terrain1'], assets.textures['terrain2']], 156 | vertScript: assets.text['terrain.vert'], 157 | fragScript: assets.text['terrain.frag'], 158 | heightMap: terraMap, 159 | heightMapScale, 160 | fogColor: FOG_COLOR, 161 | fogFar: fogDist, 162 | grassFogFar: grassFogDist, 163 | transitionLow: BEACH_TRANSITION_LOW, 164 | transitionHigh: BEACH_TRANSITION_HIGH 165 | }) 166 | meshes.terrain = terra.mesh 167 | meshes.terrain.renderOrder = 20 168 | scene.add(meshes.terrain) 169 | 170 | // Skydome 171 | meshes.sky = skydome.createMesh(assets.textures['skydome'], VIEW_DEPTH * 0.95) 172 | meshes.sky.renderOrder = 30 173 | scene.add(meshes.sky) 174 | meshes.sky.position.z = -25.0 175 | 176 | meshes.water = water.createMesh({ 177 | envMap: assets.textures['skyenv'], 178 | vertScript: assets.text['water.vert'], 179 | fragScript: assets.text['water.frag'], 180 | waterLevel: WATER_LEVEL, 181 | waterColor: WATER_COLOR, 182 | fogColor: FOG_COLOR, 183 | fogNear: 1.0, 184 | fogFar: fogDist 185 | }) 186 | meshes.water.renderOrder = 40 187 | scene.add(meshes.water) 188 | meshes.water.position.z = WATER_LEVEL 189 | 190 | // White plane to cover screen for fullscreen fade-in from white 191 | meshes.fade = new THREE.Mesh( 192 | new THREE.PlaneBufferGeometry(6.0, 4.0, 1, 1), 193 | new THREE.MeshBasicMaterial({ 194 | color: 0xFFFFFF, fog: false, transparent: true, opacity: 1.0, 195 | depthTest: false, depthWrite: false 196 | }) 197 | ) 198 | meshes.fade.position.x = 2.0 // place directly in front of camera 199 | meshes.fade.rotation.y = Math.PI * 1.5 200 | meshes.fade.renderOrder = 10 201 | camHolder.add(meshes.fade) 202 | camHolder.renderOrder = 100 203 | 204 | // Bright yellow plane for sun glare using additive blending 205 | // to blow out the colours 206 | meshes.sunFlare = new THREE.Mesh( 207 | new THREE.PlaneBufferGeometry(6.0, 4.0, 1, 1), 208 | new THREE.MeshBasicMaterial({ 209 | color: Color.to24bit(GLARE_COLOR), fog: false, transparent: true, opacity: 0.0, 210 | depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending 211 | }) 212 | ) 213 | meshes.sunFlare.position.x = 2.05 214 | meshes.sunFlare.rotation.y = Math.PI * 1.5 215 | meshes.sunFlare.visible = false 216 | meshes.sunFlare.renderOrder = 20 217 | camHolder.add(meshes.sunFlare) 218 | 219 | // Create a Player instance 220 | const player = Player(heightField, WATER_LEVEL) 221 | 222 | // For timing 223 | let prevT = Date.now() // prev frame time (ms) 224 | let simT = 0 // total running time (ms) 225 | 226 | resize(displayWidth, displayHeight) 227 | 228 | // toggle logger on ` press 229 | input.setKeyPressListener(192, () => { 230 | logger.toggle() 231 | }) 232 | 233 | input.setKeyPressListener('O'.charCodeAt(0), () => { 234 | player.state.pos.x = 0 235 | player.state.pos.y = 0 236 | }) 237 | 238 | input.setKeyPressListener('F'.charCodeAt(0), () => { 239 | windIntensity = Math.max(windIntensity - 0.1, 0) 240 | const mat = meshes.grass.material as THREE.RawShaderMaterial 241 | mat.uniforms['windIntensity'].value = windIntensity 242 | }) 243 | input.setKeyPressListener('G'.charCodeAt(0), () => { 244 | windIntensity = Math.min(windIntensity + 0.1, WIND_MAX) 245 | const mat = meshes.grass.material as THREE.RawShaderMaterial 246 | mat.uniforms['windIntensity'].value = windIntensity 247 | }) 248 | 249 | const fpsMon = FPSMonitor() 250 | 251 | /////////////////////////////////////////////////////////////////// 252 | // Public World instance methods 253 | 254 | /** 255 | * Call every frame 256 | */ 257 | function doFrame() { 258 | const curT = Date.now() 259 | let dt = curT - prevT 260 | fpsMon.update(dt) 261 | 262 | if (dt > 0) { 263 | // only do computations if time elapsed 264 | if (dt > MAX_TIMESTEP) { 265 | // don't exceed max timestep 266 | dt = MAX_TIMESTEP 267 | prevT = curT - MAX_TIMESTEP 268 | } 269 | // update sim 270 | update(dt) 271 | // render it 272 | render() 273 | // remember prev frame time 274 | prevT = curT 275 | } 276 | } 277 | 278 | /** Handle window resize events */ 279 | function resize(w: number, h: number) { 280 | displayWidth = w 281 | displayHeight = h 282 | renderer.setSize(displayWidth, displayHeight) 283 | camera.aspect = displayWidth / displayHeight 284 | camera.updateProjectionMatrix() 285 | } 286 | 287 | /////////////////////////////////////////////////////////////////// 288 | // Private instance methods 289 | 290 | const _hinfo = HInfo() 291 | const _v = Vec2.create(0.0, 0.0) 292 | 293 | /** 294 | * Logic update 295 | */ 296 | function update (dt: number) { 297 | // Intro fade from white 298 | if (simT < INTRO_FADE_DUR) { 299 | updateFade(dt) 300 | } 301 | 302 | simT += dt 303 | const t = simT * 0.001 304 | 305 | // Move player (viewer) 306 | player.update(dt) 307 | const ppos = player.state.pos 308 | const pdir = player.state.dir 309 | const pyaw = player.state.yaw 310 | const ppitch = player.state.pitch 311 | const proll = player.state.roll 312 | 313 | Heightfield.infoAt(heightField, ppos.x, ppos.y, true, _hinfo) 314 | const groundHeight = _hinfo.z 315 | 316 | if (logger.isVisible()) { 317 | logger.setText( 318 | "x:" + ppos.x.toFixed(4) + 319 | " y:" + ppos.y.toFixed(4) + 320 | " z:" + ppos.z.toFixed(4) + 321 | " dx:" + pdir.x.toFixed(4) + 322 | " dy:" + pdir.y.toFixed(4) + 323 | " dz:" + pdir.z.toFixed(4) + 324 | " height:" + groundHeight.toFixed(4) + 325 | " i:" + _hinfo.i + 326 | " fps:" + fpsMon.fps() 327 | ) 328 | } 329 | 330 | // Move skydome with player 331 | meshes.sky.position.x = ppos.x 332 | meshes.sky.position.y = ppos.y 333 | 334 | // Update grass. 335 | // Here we specify the centre position of the square patch to 336 | // be drawn. That would be directly in front of the camera, the 337 | // distance from centre to edge of the patch. 338 | const drawPos = _v 339 | Vec2.set(drawPos, 340 | ppos.x + Math.cos(pyaw) * grassPatchRadius, 341 | ppos.y + Math.sin(pyaw) * grassPatchRadius 342 | ) 343 | grass.update(meshes.grass, t, ppos, pdir, drawPos) 344 | 345 | Terrain.update(terra, ppos.x, ppos.y) 346 | 347 | water.update(meshes.water, ppos) 348 | 349 | // Update camera location/orientation 350 | Vec3.copy(ppos, camHolder.position) 351 | //camHolder.position.z = ppos.z + groundHeight 352 | camHolder.rotation.z = pyaw 353 | // Player considers 'up' pitch positive, but cam pitch (about Y) is reversed 354 | camHolder.rotation.y = -ppitch 355 | camHolder.rotation.x = proll 356 | 357 | // Update sun glare effect 358 | updateGlare() 359 | } 360 | 361 | /** Update how much glare effect by how much we're looking at the sun */ 362 | function updateGlare () { 363 | const dy = Math.abs(difAngle(GLARE_YAW, player.state.yaw)) 364 | const dp = Math.abs(difAngle(GLARE_PITCH, player.state.pitch)) * 1.75 365 | const sunVisAngle = Math.sqrt(dy * dy + dp * dp) 366 | if (sunVisAngle < GLARE_RANGE) { 367 | const glare = MAX_GLARE * Math.pow((GLARE_RANGE - sunVisAngle) / (1.0 + MAX_GLARE), 0.75) 368 | ;(meshes.sunFlare.material as THREE.MeshBasicMaterial).opacity = Math.max(0.0, glare) 369 | meshes.sunFlare.visible = true 370 | } else { 371 | meshes.sunFlare.visible = false 372 | } 373 | } 374 | 375 | /** Update intro fullscreen fade from white */ 376 | function updateFade(dt: number) { 377 | const mat = meshes.fade.material as THREE.MeshBasicMaterial 378 | if (simT + dt >= INTRO_FADE_DUR) { 379 | // fade is complete - hide cover 380 | mat.opacity = 0.0 381 | meshes.fade.visible = false 382 | } else { 383 | // update fade opacity 384 | mat.opacity = 1.0 - Math.pow(simT / INTRO_FADE_DUR, 2.0) 385 | } 386 | } 387 | 388 | function render () { 389 | renderer.render(scene, camera) 390 | } 391 | 392 | /////////////////////////////////////////////////////////////////// 393 | // Return public interface 394 | return { 395 | doFrame, 396 | resize 397 | } 398 | } 399 | 400 | export default World 401 | -------------------------------------------------------------------------------- /public/js/terra.js: -------------------------------------------------------------------------------- 1 | !function o(i,u,l){function s(t,e){if(!u[t]){if(!i[t]){var a="function"==typeof require&&require;if(!e&&a)return a(t,!0);if(c)return c(t,!0);var r=new Error("Cannot find module '"+t+"'");throw r.code="MODULE_NOT_FOUND",r}var n=u[t]={exports:{}};i[t][0].call(n.exports,function(e){return s(i[t][1][e]||e)},n,n.exports,o,i,u,l)}return u[t].exports}for(var c="function"==typeof require&&require,e=0;eE&&(g.z=-R);m.z=-y.vel.z*V,y.vel.x+=(g.x+m.x)*a,y.vel.y+=(g.y+m.y)*a,y.vel.z+=(g.z+m.z)*a,y.pos.x+=y.vel.x*a,y.pos.y+=y.vel.y*a,y.pos.z+=y.vel.z*a;var d=Math.max(b.default.heightAt(p,y.pos.x,y.pos.y,!0),v);y.pos.zC&&(y.pos.z=C)}(o.state,e):r===G&&function(e,t){var a=t/1e3;y.yawVel=0,e.left?y.yawVel=d:e.right&&(y.yawVel=-d);y.yaw+=y.yawVel*a,y.pitchVel=0,e.pitchup?y.pitchVel=B:e.pitchdown&&(y.pitchVel=-B);y.pitch+=y.pitchVel*a,y.pitch=h.clamp(y.pitch,-q,q),_.Vec3.set(y.vel,0,0,0),e.forward?(y.vel.x=c*Math.cos(y.yaw),y.vel.y=c*Math.sin(y.yaw)):e.back&&(y.vel.x=-c*Math.cos(y.yaw),y.vel.y=-c*Math.sin(y.yaw));y.pos.x+=y.vel.x*a,y.pos.y+=y.vel.y*a,e.up?y.vel.z=f:e.down&&(y.vel.z=-f);y.pos.z+=y.vel.z*a;var r=Math.max(b.default.heightAt(p,y.pos.x,y.pos.y,!0),v);y.pos.zC&&(y.pos.z=C)}(o.state,e);var t=y.dir;t.z=Math.sin(y.pitch);var a=1-Math.abs(t.z);t.x=Math.cos(y.yaw)*a,t.y=Math.sin(y.yaw)*a},state:y,getMode:function(){return r},nextMode:e}}},{"./gmath":6,"./heightfield":8,"./input":9,"./logger":11,"./notification":13,"./vec":20}],15:[function(e,t,a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});var r=(n.prototype.dot2=function(e,t){return this.x*e+this.y*t},n.prototype.dot3=function(e,t,a){return this.x*e+this.y*t+this.z*a},n);function n(e,t,a){this.x=e,this.y=t,this.z=a}var w=.5*(Math.sqrt(3)-1),M=(3-Math.sqrt(3))/6,z=new Array(512),_=new Array(512),o=[new r(1,1,0),new r(-1,1,0),new r(1,-1,0),new r(-1,-1,0),new r(1,0,1),new r(-1,0,1),new r(1,0,-1),new r(-1,0,-1),new r(0,1,1),new r(0,-1,1),new r(0,1,-1),new r(0,-1,-1)],i=[151,160,137,91,90,15,131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,190,6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,88,237,149,56,87,174,20,125,136,171,168,68,175,74,165,71,134,139,48,27,166,77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,102,143,54,65,25,63,161,1,216,80,73,209,76,132,187,208,89,18,169,200,196,135,130,116,188,159,86,164,100,109,198,173,186,3,64,52,217,226,250,124,123,5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,223,183,170,213,119,248,152,2,44,154,163,70,221,153,101,155,167,43,172,9,129,22,39,253,19,98,108,110,79,113,224,232,178,185,112,104,218,246,97,228,251,34,242,193,238,210,144,12,191,179,162,241,81,51,145,235,249,14,239,107,49,192,214,31,181,199,106,157,184,84,204,176,115,121,50,45,127,4,150,254,138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180];!function(e){0>8&255,z[t]=z[t+256]=a,_[t]=_[t+256]=o[a%12]}}(0),a.default=function(e,t){var a,r,n=(e+t)*w,o=Math.floor(e+n),i=Math.floor(t+n),u=(o+i)*M,l=e-o+u,s=t-i+u;r=s