<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Three.js Infinite Landscape</title>
<style>
body { margin: 0; }
canvas { display: block; }
#info {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 0, 0, 0.5);
color: white;
padding: 10px;
font-family: monospace;
font-size: 12px;
pointer-events: none;
}
</style>
</head>
<body>
<div id="info"></div>
<script type="importmap">
{
"imports": {
"three": "./build/three.module.js",
"three/addons/": "./jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
// Classic Perlin Noise implementation (3D for terrain variation)
class ClassicalNoise {
constructor(r = Math) {
this.grad3 = [[1,1,0],[-1,1,0],[1,-1,0],[-1,-1,0],
[1,0,1],[-1,0,1],[1,0,-1],[-1,0,-1],
[0,1,1],[0,-1,1],[0,1,-1],[0,-1,-1]];
this.p = [];
for (let i = 0; i < 256; i++) {
this.p[i] = Math.floor(r.random() * 256);
}
this.perm = [];
for (let i = 0; i < 512; i++) {
this.perm[i] = this.p[i & 255];
}
}
dot(g, x, y, z) {
return g[0] * x + g[1] * y + g[2] * z;
}
mix(a, b, t) {
return (1.0 - t) * a + t * b;
}
fade(t) {
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}
noise(x, y, z) {
let X = Math.floor(x), Y = Math.floor(y), Z = Math.floor(z);
x -= X; y -= Y; z -= Z;
X &= 255; Y &= 255; Z &= 255;
let gi000 = this.perm[X + this.perm[Y + this.perm[Z]]] % 12;
let gi001 = this.perm[X + this.perm[Y + this.perm[Z + 1]]] % 12;
let gi010 = this.perm[X + this.perm[Y + 1 + this.perm[Z]]] % 12;
let gi011 = this.perm[X + this.perm[Y + 1 + this.perm[Z + 1]]] % 12;
let gi100 = this.perm[X + 1 + this.perm[Y + this.perm[Z]]] % 12;
let gi101 = this.perm[X + 1 + this.perm[Y + this.perm[Z + 1]]] % 12;
let gi110 = this.perm[X + 1 + this.perm[Y + 1 + this.perm[Z]]] % 12;
let gi111 = this.perm[X + 1 + this.perm[Y + 1 + this.perm[Z + 1]]] % 12;
let n000 = this.dot(this.grad3[gi000], x, y, z);
let n100 = this.dot(this.grad3[gi100], x - 1, y, z);
let n010 = this.dot(this.grad3[gi010], x, y - 1, z);
let n110 = this.dot(this.grad3[gi110], x - 1, y - 1, z);
let n001 = this.dot(this.grad3[gi001], x, y, z - 1);
let n101 = this.dot(this.grad3[gi101], x - 1, y, z - 1);
let n011 = this.dot(this.grad3[gi011], x, y - 1, z - 1);
let n111 = this.dot(this.grad3[gi111], x - 1, y - 1, z - 1);
let u = this.fade(x), v = this.fade(y), w = this.fade(z);
let nx00 = this.mix(n000, n100, u);
let nx01 = this.mix(n001, n101, u);
let nx10 = this.mix(n010, n110, u);
let nx11 = this.mix(n011, n111, u);
let nxy0 = this.mix(nx00, nx10, v);
let nxy1 = this.mix(nx01, nx11, v);
return this.mix(nxy0, nxy1, w);
}
}
function lerp(start,end,t) {
return start * (1 - t) + end * t;
}
const perlin = new ClassicalNoise();
// Scene setup
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 10000);
camera.position.set(-21.15, 60.31, -195.56); // Flying height
camera.lookAt(0, 0, -100); // Look forward along -z
//camera.rotation.set(-162.86, -5.90, -178.18);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
const gl = renderer.getContext();
if (gl) {
const ext = gl.getExtension('GL_EXT_tessellation_shader');
if (ext) {
gl.patchParameteri(gl.PATCH_VERTICES, 4); // for quads
} else {
console.error('Tessellation extension not supported');
}
}
const controls = new OrbitControls(camera, renderer.domElement); // For debugging
//const radian=180.0 / Math.PI;
const radian=Math.PI / 180.0;
function getDirection(theta, phi) { // dir = return
let theta_left = theta + 90.0;
let phi_up = phi - 90.0;
if(theta_left>180.0) theta_left -=360.0;
if(phi_up<-180.0) phi_up += 360.0;
//const x=Math.sin(90.0 * radian) * Math.cos(theta_left * radian);
//const y=Math.cos(90.0 * radian);
//const z=Math.sin(90.0 * radian) * Math.sin(theta_left * radian);
const x=Math.sin(phi_up * radian) * Math.cos(theta * radian);
const y=Math.cos(phi_up * radian);
const z=Math.sin(phi_up * radian) * Math.sin(theta * radian);
//const x=Math.sin(phi * radian) * Math.cos(theta * radian);
//const y=Math.cos(phi);
//const z=Math.sin(phi * radian) * Math.sin(theta * radian);
//Vec3 XZonly={ // for player full directional speed not Y
// cos(theta * radian),
// 0,
// sin(theta * radian)
//};
return new THREE.Vector3(x,y,z);
}
var sunTime=getDirection(90,170); // theta orient around horizontally, phi vertical/altitude
// sunrise/sunset = phi at 180, above ground at 170, below ground at 190
// front facing with camera at 90, back facing at -90 or 270
// still need the light position itself
// Lights (PBR-friendly)
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8); // Increased ambient for better brightness
scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xffffff, 1.5); // Increased intensity
//sunLight.position.set(100, 100, 100); // Sun position for shadows/lighting (match sunDirection in skyMaterial)
sunLight.position.set(sunTime.x*100,sunTime.y*100,sunTime.z*100); // Sun position from sphere coords
scene.add(sunLight);
//scene.fog = new THREE.FogExp2(0xaaccff, 0.00008); // color ≈ sky horizon, very low density
// Tune density: 0.00005 – 0.00015 depending on how fast you want fade
// Color should roughly match your sky bottomColor or horizon
// Fog - tuned for visibility
//scene.fog = new THREE.FogExp2(0xffffff, 0.0001); // Low density, match sky bottomColor
scene.fog = new THREE.FogExp2(0xaaccff, 0.00008); // start low, increase to 0.00015–0.0003 to see effect faster
const skyColors={
topColor: [
[80,124,255], // morning blue
[57,206,255], // day cyan blue
[16,4,255], // evening deep blue
[3,0,74], // night dark blue
],
bottomColor: [
[183,255,245],// morning cyan white
[198,212,255],// day blue white
[186,89,255], // evening purple
[12,3,175], // night dim blue
],
};
// calculate the 86400 seconds to 4 segments time,
// but not equally sized? day/night = 16hr~, morning/evening=8hr~
// 86400/4 = 21600
// 16/2 = 8 * 60 * 60 = 28800 / 2 = 14400 (4hr)
//
// morning= 1 to 14400 (+14400)
// day= 14401 to 43200 (+28800)
// evening= 43201 to 57600 (+14400)
// night= 57601 to 86400 (+28800)
//
// cont in animate
// Sky sphere (WoW-like gradient with sun)
const skyGeometry = new THREE.SphereGeometry(5000, 32, 32);
const skyMaterial = new THREE.ShaderMaterial({
uniforms: {
//topColor: { value: new THREE.Color(0x0077ff) }, // Blue sky
//bottomColor: { value: new THREE.Color(0xffffff) }, // Horizon haze
topColor: { value: new THREE.Color(0x6677ff) }, // Blue sky
bottomColor: { value: new THREE.Color(0xffeeff) }, // Horizon haze
sunColor: { value: new THREE.Color(0xfffffe) }, // Sun glow
//sunDirection: { value: new THREE.Vector3(0.2, 0.2, 1).normalize() }, // Sun pos (normalized)
sunDirection: { value: sunTime.normalize() }, // Sun pos (normalized) from sphere coords
sunSize: { value: 0.003 }, // Sun disc size
},
vertexShader: `
varying vec3 vWorldPosition;
void main() {
vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 topColor;
uniform vec3 bottomColor;
uniform vec3 sunColor;
uniform vec3 sunDirection;
uniform float sunSize;
varying vec3 vWorldPosition;
void main() {
vec3 viewDir = normalize(vWorldPosition - cameraPosition);
float height = normalize(vWorldPosition).y;
vec3 gradient = mix(bottomColor, topColor, max(height, 0.0)); // Gradient based on y
float sunDot = dot(viewDir, sunDirection);
float sunGlow = smoothstep(1.0 - sunSize, 1.0, sunDot); // Blended sun disc
gl_FragColor = vec4(mix(gradient, sunColor, sunGlow * 10.0), 1.0);
}
`,
side: THREE.BackSide, // Inside out
depthWrite: false,
fog: false, // No fog on sky
});
const sky = new THREE.Mesh(skyGeometry, skyMaterial);
scene.add(sky);
// Terrain setup
const tileSize = 4000; // Widened to hide edges easier
const segments = 512; // Increased for better detail/resolution
const heightScale = 50;
const noiseScale = 100;
const speed = 50; // Flying speed
const numTiles = 5; // Keep this many tiles visible
const terrainGroup = new THREE.Group();
scene.add(terrainGroup);
const textureLoader = new THREE.TextureLoader();
// Load textures (replace paths; ensure they exist)
const grassTex = textureLoader.load('./textures/grass.png');
grassTex.wrapS = grassTex.wrapT = THREE.RepeatWrapping;
grassTex.repeat.set(40, 40); // Increased repeat for larger tiles/better scaling
const rockTex = textureLoader.load('./textures/rock.png');
rockTex.wrapS = rockTex.wrapT = THREE.RepeatWrapping;
rockTex.repeat.set(40, 40);
const snowTex = textureLoader.load('./textures/snow.png');
snowTex.wrapS = snowTex.wrapT = THREE.RepeatWrapping;
snowTex.repeat.set(40, 40);
const normalGrass = textureLoader.load('./textures/normal_grass.png');
normalGrass.wrapS = normalGrass.wrapT = THREE.RepeatWrapping;
normalGrass.repeat.set(40, 40);
const normalRock = textureLoader.load('./textures/normal_rock.png');
normalRock.wrapS = normalRock.wrapT = THREE.RepeatWrapping;
normalRock.repeat.set(40, 40);
const normalSnow = textureLoader.load('./textures/normal_snow.png');
normalSnow.wrapS = normalSnow.wrapT = THREE.RepeatWrapping;
normalSnow.repeat.set(40, 40);
// GLSL Perlin noise
const glslPerlin = `
vec3 permute(vec3 x) { return mod(((x*34.0)+1.0)*x, 289.0); }
float snoise(vec2 v){
const vec4 C = vec4(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
vec2 i = floor(v + dot(v, C.yy) );
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1;
i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod(i, 289.0);
vec3 p = permute( permute( i.y + vec3(0.0, i1.y, 1.0 )) + i.x + vec3(0.0, i1.x, 1.0 ));
vec3 m = max(0.5 - vec3(dot(x0,x0), dot(x12.xy,x12.xy), dot(x12.zw,x12.zw)), 0.0);
m = m*m ;
m = m*m ;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * ( a0*a0 + h*h );
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
`;
// Terrain material with tessellation
//const terrainMaterial = new THREE.ShaderMaterial({
const terrainMaterial = new THREE.RawShaderMaterial({
//uniforms: THREE.UniformsUtils.merge([
// THREE.UniformsLib.lights,
// THREE.UniformsLib.fog,
// {
uniforms: {
grassTex: { value: grassTex },
rockTex: { value: rockTex },
snowTex: { value: snowTex },
normalGrass: { value: normalGrass },
normalRock: { value: normalRock },
normalSnow: { value: normalSnow },
repeatScale: { value: 40.0 },
lightDir: { value: sunLight.position.normalize() },
modelMatrix: { value: new THREE.Matrix4() }, // if needed in TCS/TES
modelViewMatrix: { value: new THREE.Matrix4() },
projectionMatrix: { value: new THREE.Matrix4() },
normalMatrix: { value: new THREE.Matrix3() },
// Fog uniforms (must match THREE.FogExp2)
fogColor: { value: new THREE.Color(0xaaccff) }, // match your sky
fogDensity: { value: 0.00008 },
cameraPos: { value: camera.position }, // For distance in TCS
},
// }
//]),
vertexShader: `
// Built-in inputs from geometry attributes (you MUST declare them yourself)
in vec3 position;
in vec2 uv;
in vec3 normal;
// Pass-through to TCS (these are the outs)
out vec3 vPosition;
out vec2 vUv;
out vec3 vNormal;
// Dummy outputs – just to satisfy fragment linker
// Use same name/type as in TES → fragment chain
out vec3 vWorldPos;
out float vHeight;
out float vFogDepth;
void main() {
// Simply pass the built-in inputs forward
vPosition = position;
vUv = uv;
vNormal = normal;
// Dummy assignments – prevent unused varying warning + satisfy linker
vWorldPos = vec3(0.0); // arbitrary value, will be overwritten in TES
vHeight = 0.0;
vFogDepth = 0.0;
// No need to compute gl_Position here — TCS/TES will do it later
// Some drivers require something, so minimal passthrough is ok:
gl_Position = vec4(position, 1.0); // dummy, will be ignored/overwritten
}
`,
tessellationControlShader: `
#version 300 es
layout(vertices = 4) out;
in vec3 vPosition[];
in vec2 vUv[];
in vec3 vNormal[];
out vec3 tcPosition[];
out vec2 tcUv[];
out vec3 tcNormal[];
uniform vec3 cameraPos; // ← your uniform
#define MAX_LEVEL 64.0
#define MIN_LEVEL 4.0
float getTessLevel(float dist) {
return mix(MAX_LEVEL, MIN_LEVEL, smoothstep(500.0, 3000.0, dist));
}
void main() {
// Copy inputs to outputs (now legal, since outs are writable)
tcPosition[gl_InvocationID] = vPosition[gl_InvocationID];
tcUv[gl_InvocationID] = vUv[gl_InvocationID];
tcNormal[gl_InvocationID] = vNormal[gl_InvocationID];
// Only compute tess levels once (invocation 0)
if (gl_InvocationID == 0) {
vec3 p0 = (modelMatrix * vec4(vPosition[0], 1.0)).xyz;
vec3 p1 = (modelMatrix * vec4(vPosition[1], 1.0)).xyz;
vec3 p2 = (modelMatrix * vec4(vPosition[2], 1.0)).xyz;
vec3 p3 = (modelMatrix * vec4(vPosition[3], 1.0)).xyz;
float d0 = distance(cameraPos, p0);
float d1 = distance(cameraPos, p1);
float d2 = distance(cameraPos, p2);
float d3 = distance(cameraPos, p3);
float e0 = getTessLevel((d1 + d2) * 0.5);
float e1 = getTessLevel((d2 + d3) * 0.5);
float e2 = getTessLevel((d3 + d0) * 0.5);
float e3 = getTessLevel((d0 + d1) * 0.5);
gl_TessLevelOuter[0] = e0;
gl_TessLevelOuter[1] = e1;
gl_TessLevelOuter[2] = e2;
gl_TessLevelOuter[3] = e3;
gl_TessLevelInner[0] = max(e1, e3);
gl_TessLevelInner[1] = max(e0, e2);
//--gl_TessLevelOuter[0] = 32.0;
//--gl_TessLevelOuter[1] = 32.0;
//--gl_TessLevelOuter[2] = 32.0;
//--gl_TessLevelOuter[3] = 32.0;
//--gl_TessLevelInner[0] = 32.0;
//--gl_TessLevelInner[1] = 32.0;
// Debug force high subdivision (uncomment to test)
// gl_TessLevelOuter = vec4(32.0);
// gl_TessLevelInner = vec2(32.0);
}
}
`,
tessellationEvaluationShader: `
#version 300 es
layout(quads, fractional_even_spacing, ccw) in;
in vec3 tcPosition[];
in vec2 tcUv[];
in vec3 tcNormal[];
out vec2 vUv;
out float vHeight;
out vec3 vNormal;
out vec3 vWorldPos;
out float vFogDepth;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
uniform mat3 normalMatrix;
// Ported ClassicalNoise to GLSL (exact match to your JS version)
struct ClassicalNoiseGLSL {
ivec3[12] grad3;
int[512] perm;
};
float dot(ivec3 g, vec3 xyz) {
return float(g.x) * xyz.x + float(g.y) * xyz.y + float(g.z) * xyz.z;
}
float mix(float a, b, t) {
return (1.0 - t) * a + t * b;
}
float fade(float t) {
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}
float noise(ClassicalNoiseGLSL cn, vec3 p) {
vec3 P = p;
ivec3 Pi = ivec3(floor(P));
vec3 Pf = P - vec3(Pi);
Pi = Pi & 255;
int gi000 = cn.perm[Pi.x + cn.perm[Pi.y + cn.perm[Pi.z]] % 12;
int gi001 = cn.perm[Pi.x + cn.perm[Pi.y + cn.perm[Pi.z + 1]] % 12;
int gi010 = cn.perm[Pi.x + cn.perm[Pi.y + 1 + cn.perm[Pi.z]] % 12;
int gi011 = cn.perm[Pi.x + cn.perm[Pi.y + 1 + cn.perm[Pi.z + 1]] % 12;
int gi100 = cn.perm[Pi.x + 1 + cn.perm[Pi.y + cn.perm[Pi.z]] % 12;
int gi101 = cn.perm[Pi.x + 1 + cn.perm[Pi.y + cn.perm[Pi.z + 1]] % 12;
int gi110 = cn.perm[Pi.x + 1 + cn.perm[Pi.y + 1 + cn.perm[Pi.z]] % 12;
int gi111 = cn.perm[Pi.x + 1 + cn.perm[Pi.y + 1 + cn.perm[Pi.z + 1]] % 12;
float n000 = dot(cn.grad3[gi000], Pf - vec3(0.0,0.0,0.0));
float n100 = dot(cn.grad3[gi100], Pf - vec3(1.0,0.0,0.0));
float n010 = dot(cn.grad3[gi010], Pf - vec3(0.0,1.0,0.0));
float n110 = dot(cn.grad3[gi110], Pf - vec3(1.0,1.0,0.0));
float n001 = dot(cn.grad3[gi001], Pf - vec3(0.0,0.0,1.0));
float n101 = dot(cn.grad3[gi101], Pf - vec3(1.0,0.0,1.0));
float n011 = dot(cn.grad3[gi011], Pf - vec3(0.0,1.0,1.0));
float n111 = dot(cn.grad3[gi111], Pf - vec3(1.0,1.0,1.0));
vec3 fade_xyz = fade(Pf);
float nx00 = mix(n000, n100, fade_xyz.x);
float nx01 = mix(n001, n101, fade_xyz.x);
float nx10 = mix(n010, n110, fade_xyz.x);
float nx11 = mix(n011, n111, fade_xyz.x);
float nxy0 = mix(nx00, nx10, fade_xyz.y);
float nxy1 = mix(nx01, nx11, fade_xyz.y);
return mix(nxy0, nxy1, fade_xyz.z);
}
ClassicalNoiseGLSL cn = ClassicalNoiseGLSL(); // Initialize in main or uniform if possible, but since static, define inside main
// In main, initialize grad3 and perm with your values (long, but copy from JS)
cn.grad3[0] = ivec3(1,1,0); cn.grad3[1] = ivec3(-1,1,0); // ... copy all 12
// cn.perm = int[512](0,1,2...); // copy the perm array from JS (it's fixed, so hardcode)
void main() {
float u = gl_TessCoord.x;
float v = gl_TessCoord.y;
// Interpolation (same)
vec3 pos = mix(mix(tcPosition[0], tcPosition[1], u), mix(tcPosition[3], tcPosition[2], u), v);
vUv = mix(mix(tcUv[0], tcUv[1], u), mix(tcUv[3], tcUv[2], u), v) * 40.0;
vNormal = normalize(normalMatrix * mix(mix(tcNormal[0], tcNormal[1], u), mix(tcNormal[3], tcNormal[2], u), v));
// World pos for noise
vec4 worldPos = modelMatrix * vec4(pos, 1.0);
float noise = noise(cn, vec3(worldPos.x / 100.0, worldPos.z / 100.0, 0.0)); // your noiseScale=100
pos.y += 500.0 * (noise + 0.5); // Boost to 500 for visible (test, then back to 50)
// Debug sin (add if noise not visible)
pos.y += 500.0 * sin(u * 2.0 * 3.14159 * 5.0) * sin(v * 2.0 * 3.14159 * 5.0); // Higher freq/amplitude
vHeight = pos.y / 50.0; // Original heightScale
vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0);
vWorldPos = (modelMatrix * vec4(pos, 1.0)).xyz;
vFogDepth = -mvPosition.z;
gl_Position = projectionMatrix * mvPosition;
// Approximate normal for lighting (after displacement)
vec3 dx = dFdx(vWorldPos);
vec3 dy = dFdy(vWorldPos);
vNormal = normalize(cross(dx, dy));
}
`,
fragmentShader: `
precision highp float;
// Uniforms (declare all you use)
uniform sampler2D grassTex;
uniform sampler2D rockTex;
uniform sampler2D snowTex;
uniform sampler2D normalGrass;
uniform sampler2D normalRock;
uniform sampler2D normalSnow;
uniform float repeatScale;
uniform vec3 lightDir;
uniform vec3 cameraPos; // ← your uniform
// Varyings from TES
in vec2 vUv;
in float vHeight;
in vec3 vNormal;
in vec3 vWorldPos;
in float vFogDepth;
// Fog uniforms (from THREE.UniformsLib.fog or manual)
uniform vec3 fogColor;
uniform float fogDensity;
// Output color (replaces gl_FragColor)
out vec4 fragColor;
void main() {
// Texture lookups - use texture() not texture2D
vec3 grassColor = texture(grassTex, vUv * repeatScale).rgb;
vec3 rockColor = texture(rockTex, vUv * repeatScale).rgb;
vec3 snowColor = texture(snowTex, vUv * repeatScale).rgb;
vec3 albedo = mix(grassColor, rockColor, smoothstep(0.2, 0.5, vHeight));
albedo = mix(albedo, snowColor, smoothstep(0.6, 0.8, vHeight));
// Normals splatting
vec3 normGrass = texture(normalGrass, vUv * repeatScale).rgb;
vec3 normRock = texture(normalRock, vUv * repeatScale).rgb;
vec3 normSnow = texture(normalSnow, vUv * repeatScale).rgb;
vec3 norm = mix(normGrass, normRock, smoothstep(0.2, 0.5, vHeight));
norm = mix(norm, normSnow, smoothstep(0.6, 0.8, vHeight));
norm = norm * 2.0 - 1.0; // unpack [-1,1]
// Hardcoded ORM values (as before)
float ao = 1.0;
float rough = 0.5;
float metal = 0.0;
// Lighting
vec3 finalNormal = normalize(vNormal + norm * 0.5);
float diff = max(dot(lightDir, finalNormal), 0.0) * ao;
// Simple specular
vec3 viewDir = normalize(cameraPos - vWorldPos); // ← see uniform below
vec3 halfway = normalize(lightDir + viewDir);
float spec = pow(max(dot(finalNormal, halfway), 0.0), 32.0 * (1.0 - rough)) * (0.04 + metal);
vec3 color = albedo * (diff + 0.3) + vec3(spec);
// Fog application (using your vFogDepth)
float fogFactor = 1.0 - exp(-fogDensity * fogDensity * vFogDepth * vFogDepth);
color = mix(color, fogColor, fogFactor);
fragColor = vec4(color, 1.0);
}
`,
glslVersion: THREE.GLSL3, // For #version 300 es
side: THREE.DoubleSide,
fog: true,
lights: false // For directional light or false for full custom
});
terrainMaterial.wireframe = true; // uncomment temporarily
// For quad geometry
function createQuadGeometry(size, divs) {
// "your" custom createQuadGeometry already generates the plane in the XZ plane (with y=0 for all vertices, x and z varying). This is different from PlaneGeometry, which defaults to the XY plane and requires the -Math.PI / 2 rotation to lay flat (grok x))
const geometry = new THREE.BufferGeometry();
const vertices = [];
const uvs = [];
const normals = [];
const indices = [];
const divSize = size / divs;
for (let y = 0; y < divs; y++) {
for (let x = 0; x < divs; x++) {
const idx = vertices.length / 3;
vertices.push(x * divSize - size / 2, 0, y * divSize - size / 2);
uvs.push(x / divs, y / divs);
normals.push(0, 1, 0);
vertices.push((x + 1) * divSize - size / 2, 0, y * divSize - size / 2);
uvs.push((x + 1) / divs, y / divs);
normals.push(0, 1, 0);
vertices.push((x + 1) * divSize - size / 2, 0, (y + 1) * divSize - size / 2);
uvs.push((x + 1) / divs, (y + 1) / divs);
normals.push(0, 1, 0);
vertices.push(x * divSize - size / 2, 0, (y + 1) * divSize - size / 2);
uvs.push(x / divs, (y + 1) / divs);
normals.push(0, 1, 0);
indices.push(idx, idx + 1, idx + 2, idx, idx + 2, idx + 3); // Triangles for quads, but for tess, we can use patches but Three.js will handle
}
}
geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
geometry.setIndex(indices);
return geometry;
}
// Function to get height at world position (using same noise)
function getHeightAt(worldX, worldZ) {
return heightScale * (perlin.noise(worldX / noiseScale, worldZ / noiseScale, 0) + 0.5);
}
function createTile(zOffset) {
// negative z index start pos can cause offset difference on first 3 tiles compared to following 2 and all other if using worldpos for noise
//console.log('createTile: '+zOffset);
const baseDivs = 8; // ← increased from 4 → better starting tessellation density
const geometry = createQuadGeometry(tileSize, baseDivs); // your custom quad grid function
// Important: rotate the geometry to lay flat (XZ plane)
//geometry.rotateX(-Math.PI / 2);
const mesh = new THREE.Mesh(geometry, terrainMaterial);
mesh.position.z = zOffset;
// Optional debug: make it easier to see the base mesh
//mesh.material.wireframe = true; // uncomment temporarily
// No CPU height update needed anymore — displacement happens in TES
return mesh;
}
function updateTileHeights(tile) {
const geometry = tile.geometry;
const vertices = geometry.attributes.position.array;
for (let i = 0; i < vertices.length; i += 3) {
const worldX = vertices[i] + tile.position.x;
const worldZ = vertices[i + 2] + tile.position.z;
vertices[i + 1] = getHeightAt(worldX, worldZ);
}
geometry.attributes.position.needsUpdate = true;
geometry.computeVertexNormals();
}
// Function to add mountains to a tile (called on create and recycle)
function addMountainsToTile(tile) {
const mountainGeo = new THREE.ConeGeometry(20, 40, 32); // Larger mountains
const mountainMat = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.8, metalness: 0 });
for (let i = 0; i < 4; i++) { // Add a few per tile
const posX = Math.random() * tileSize - tileSize / 2;
const posZ = Math.random() * tileSize - tileSize / 2;
const height = getHeightAt(posX + tile.position.x, posZ + tile.position.z);
const mountain = new THREE.Mesh(mountainGeo, mountainMat);
mountain.position.set(posX, height - 5, posZ); // Slightly below surface
mountain.rotation.y = Math.random() * Math.PI * 2; // Random rotate
tile.add(mountain);
}
// Example GLTF load and parent (async, replace with your model)
// const loader = new GLTFLoader();
// loader.load('./models/example.gltf', (gltf) => {
// const model = gltf.scene;
// const posX = Math.random() * tileSize - tileSize / 2;
// const posZ = Math.random() * tileSize - tileSize / 2;
// const height = getHeightAt(posX + tile.position.x, posZ + tile.position.z);
// model.position.set(posX, height, posZ);
// model.scale.set(5, 5, 5);
// tile.add(model);
// });
}
// Initialize tiles
const tiles = [];
for (let i = 0; i < numTiles; i++) {
const tile = createTile(i * tileSize - (numTiles / 2 * tileSize));
addMountainsToTile(tile);
tiles.push(tile);
terrainGroup.add(tile);
}
var dd=null;
var hh=0;
var mm=0;
var ss=0;
var tsp=0;
var ss_theta=0;
var ss_phi=0;
var testval=0;
var tmpColor={};
// Animation loop
let lastTime = 0;
const infoDiv = document.getElementById('info');
function animate(time) {
requestAnimationFrame(animate);
const dt = (time - lastTime) / 1000;
lastTime = time;
// Move world toward camera
terrainGroup.position.z -= speed * dt;
// Recycle tiles
const firstTile = tiles[0];
if (firstTile.position.z + terrainGroup.position.z < -tileSize) {
tiles.shift();
firstTile.position.z += numTiles * tileSize;
updateTileHeights(firstTile);
while (firstTile.children.length) firstTile.remove(firstTile.children[0]);
addMountainsToTile(firstTile);
tiles.push(firstTile);
}
terrainMaterial.uniforms.cameraPos.value.copy(camera.position);
// Optional – if you use them in TES/TCS
terrainMaterial.uniforms.modelViewMatrix.value.multiplyMatrices(
camera.matrixWorldInverse,
terrainGroup.matrixWorld // or per-tile if needed
);
terrainMaterial.uniforms.projectionMatrix.value.copy(camera.projectionMatrix);
terrainMaterial.uniforms.normalMatrix.value.getNormalMatrix(terrainGroup.matrixWorld); // or per mesh
// Update info box
const pos = camera.position;
const rot = camera.rotation;
infoDiv.innerHTML = `
Camera Position:<br>
X: ${pos.x.toFixed(2)}<br>
Y: ${pos.y.toFixed(2)}<br>
Z: ${pos.z.toFixed(2)}<br><br>
Camera Rotation:<br>
X: ${(rot.x * 180 / Math.PI).toFixed(2)}°<br>
Y: ${(rot.y * 180 / Math.PI).toFixed(2)}°<br>
Z: ${(rot.z * 180 / Math.PI).toFixed(2)}°
`;
// sun position calculations
dd=new Date();
hh=dd.getHours()*60*60;
mm=dd.getMinutes()*60;
ss=dd.getSeconds()+mm+hh;
//ss+=(6*60*60); // offset time, - 4-5 hours seem about accurate for sunset at 6pm front
ss-=(12*60*60);
if(ss>86400) ss-=86400;
//testval++;
//if(testval>2000) testval-=2000;
//tsp=testval/1000; // debugtime
tsp=ss/43200; // realtime
ss_theta=(90);
ss_phi=((tsp)*180)+90;
sunTime=getDirection(ss_theta,ss_phi); // 90,170 = default front above ground, 180 phi = on ground
//sunPhi should be 180 at 6am and 6pm, going to 300 at night and 60 on day
//sunTheta should be 90 at 6pm, -90 or 270 at 6am
sunLight.position.set(sunTime.x*1,sunTime.y*1,sunTime.z*1);
skyMaterial.uniforms.sunDirection.value = sunTime.normalize();
// sky color calculations
var l=0; // segment selection
//
// realtime
var t=ss; // range current
var m=14400; // range max
if(ss>57600) { // night
l=3;
t=ss-57600;
m=28800;
} else if(ss>43200) { // evening
l=2;
t=ss-43200;
m=14400;
} else if(ss>14400) { // day
l=1;
t=ss-14400;
m=28800;
}
//
//debugtime
//ss=testval;
// morning
//var t=ss; // range current
//var m=200; // range max
//if(ss>1200) { // night
// l=3;
// t=ss-1200;
// m=800;
//} else if(ss>1000) { // evening
// l=2;
// t=ss-1000;
// m=200;
//} else if(ss>200) { // day
// l=1;
// t=ss-200;
// m=800;
//}
//
//console.log("l="+l+" t="+t+" m="+m);
var l2=l+1;
if(l2>3) l2=0;
tmpColor['r1']=skyColors['topColor'][l][0];
tmpColor['g1']=skyColors['topColor'][l][1];
tmpColor['b1']=skyColors['topColor'][l][2];
tmpColor['r2']=skyColors['topColor'][l2][0];
tmpColor['g2']=skyColors['topColor'][l2][1];
tmpColor['b2']=skyColors['topColor'][l2][2];
tmpColor['r3']=skyColors['bottomColor'][l][0];
tmpColor['g3']=skyColors['bottomColor'][l][1];
tmpColor['b3']=skyColors['bottomColor'][l][2];
tmpColor['r4']=skyColors['bottomColor'][l2][0];
tmpColor['g4']=skyColors['bottomColor'][l2][1];
tmpColor['b4']=skyColors['bottomColor'][l2][2];
tmpColor['rlt']=lerp(tmpColor['r1'],tmpColor['r2'],t/m);
tmpColor['glt']=lerp(tmpColor['g1'],tmpColor['g2'],t/m);
tmpColor['blt']=lerp(tmpColor['b1'],tmpColor['b2'],t/m);
tmpColor['rlb']=lerp(tmpColor['r3'],tmpColor['r4'],t/m);
tmpColor['glb']=lerp(tmpColor['g3'],tmpColor['g4'],t/m);
tmpColor['blb']=lerp(tmpColor['b3'],tmpColor['b4'],t/m);
skyMaterial.uniforms.topColor.value=new THREE.Color(tmpColor['rlt']/255,tmpColor['glt']/255,tmpColor['blt']/255);
skyMaterial.uniforms.bottomColor.value=new THREE.Color(tmpColor['rlb']/255,tmpColor['glb']/255,tmpColor['blb']/255);
// sun and sky calculations end
controls.update();
renderer.render(scene, camera);
}
animate(0);
// Resize handler
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
</body>
</html>
Top