feat(dashboard): terrain lit by the zone's lights, blending with the scenery

The textured terrain in the world and property 3D views now takes the
lighting the scenery uses (the scene's sun and ambient light, blending as
the focus moves between scenes), as TiledDetailDiffuse_4_PS does: the
blended textures times the diffuse map times two times the clamped vertex
light, times the blend alpha. Until a manifest brings the lighting, the
terrain keeps its own light.

Checked against the client (RAWReadColorandLightMaps 0x0102e3e0 and the
texture map reader at 0x0103aaf0): the game's diffuse map and blend map are
DDS copies of the color and texture maps the views use (same pixels on the
Avant Gardens chunks compared), so the dark patches and the steep drops at
the plateau's edges are in the terrain file itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 22:20:07 -05:00
parent 5c2cda1223
commit 966a12f4af
4 changed files with 27 additions and 7 deletions

View File

@@ -304,6 +304,9 @@ void main() {
const TERRAIN_FRAGMENT = `
uniform sampler2D texture1, texture2, texture3, texture4, blendMap, colorMap, sceneMap, scenePalette;
uniform vec3 lightDirection;
// The zone's lights as the game's TerrainDiffuse.fx gets them (scenery.js gameLights), when gameLightOn
uniform vec3 gameLightColor, gameAmbient, gameLightVec;
uniform float gameLightOn;
uniform int look;
uniform float sceneSize;
varying vec2 vUv;
@@ -327,6 +330,12 @@ void main() {
color = mix(color, texture2D(texture2, tiled), blend.r);
color = mix(color, texture2D(texture3, tiled), blend.g);
color = mix(color, texture2D(texture4, tiled), blend.b);
if (gameLightOn > 0.5) {
// TiledDetailDiffuse_4_PS: textures * diffuse map * 2 * (sun * N.L + ambient, clamped as a vertex color), * blend alpha
vec3 lit = clamp(gameLightColor * max(0.0, dot(normalize(vNormal), gameLightVec)) + gameAmbient, 0.0, 1.0);
gl_FragColor = vec4(min(color.rgb * tint * lit * blend.a, 1.0), 1.0);
return;
}
gl_FragColor = vec4(min(color.rgb * tint, 1.0) * light * blend.a, 1.0);
}`;
@@ -345,7 +354,7 @@ function sceneTexture(base64, size) {
* Returns the group, a function giving the ground height at (x, z), and setLook(look, layers): switch to one of
* TERRAIN_LOOKS; the scene look needs the zone's terrain layers (/api/world3d/:zone/terrain_layers).
*/
export function buildTerrainChunks(data, loadTexture, lightDirection) {
export function buildTerrainChunks(data, loadTexture, lightDirection, gameLights = null) {
const group = new THREE.Group();
const grids = [];
const materials = [];
@@ -380,7 +389,12 @@ export function buildTerrainChunks(data, loadTexture, lightDirection) {
texture3: { value: loadTexture(chunk.textures[2]) }, texture4: { value: loadTexture(chunk.textures[3]) },
blendMap: { value: bgraTexture(chunk.blend, chunk.blendSize) }, colorMap: { value: bgraTexture(chunk.color, chunk.colorSize) },
sceneMap: { value: null }, scenePalette: { value: null }, sceneSize: { value: 1 },
lightDirection: { value: lightDirection }, look: { value: 0 }
lightDirection: { value: lightDirection }, look: { value: 0 },
// Shared with the scenery, so the terrain follows its lighting (and its blends between scenes)
gameLightColor: gameLights ? gameLights.gameLightColor : { value: new THREE.Vector3() },
gameAmbient: gameLights ? gameLights.gameAmbient : { value: new THREE.Vector3() },
gameLightVec: gameLights ? gameLights.gameLightVec : { value: new THREE.Vector3(0, 1, 0) },
gameLightOn: gameLights ? gameLights.gameLightOn : { value: 0 }
}
});
const mesh = new THREE.Mesh(geometry, material);
@@ -772,7 +786,7 @@ export function createViewer(container, { onProgress, onSelect, onTick, brickUrl
* The zone's whole terrain as the game draws it (null removes it): data from /api/properties/:id/terrain_chunks,
* textures from textureUrl(id).
*/
setTerrainChunks(data, textureUrl) {
setTerrainChunks(data, textureUrl, gameLights = null) {
removeTerrain();
if (data && data.chunks && data.chunks.length) {
const loader = new THREE.TextureLoader();
@@ -788,7 +802,7 @@ export function createViewer(container, { onProgress, onSelect, onTick, brickUrl
return textures.get(id);
};
const sunDirection = sun.position.clone().sub(sun.target.position).normalize();
const built = buildTerrainChunks(data, loadTexture, sunDirection);
const built = buildTerrainChunks(data, loadTexture, sunDirection, gameLights);
terrain = built.group;
groundHeight = built.heightAt;
scene.add(terrain);

View File

@@ -184,8 +184,12 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
}
// The zone's lights as the game's shaders get them (manifest.lighting), shared by every lit material
const gameLights = { gameLightColor: { value: new THREE.Vector3(1, 1, 1) }, gameAmbient: { value: new THREE.Vector3() }, gameLightVec: { value: new THREE.Vector3(0, 1, 0) } };
const gameLights = {
gameLightColor: { value: new THREE.Vector3(1, 1, 1) }, gameAmbient: { value: new THREE.Vector3() }, gameLightVec: { value: new THREE.Vector3(0, 1, 0) },
gameLightOn: { value: 0 } // 1 once a manifest brought the zone's lighting (the terrain uses its own light until then)
};
function setGameLights(lighting) {
gameLights.gameLightOn.value = lighting ? 1 : 0;
if (!lighting) return;
gameLights.gameLightColor.value.fromArray(lighting.light);
gameLights.gameAmbient.value.fromArray(lighting.ambient);
@@ -569,6 +573,8 @@ export function createScenery({ scene, camera, renderer, urls, focus, onProgress
sceneState() {
return { mode: sceneMode, scene: focusScene, shown: shownScenes, scenes: manifest && manifest.scenes ? manifest.scenes : [] };
},
/** The zone's lights as uniforms (updated in place, blends too), for the terrain to be lit like the scenery. */
gameLights() { return gameLights; },
/** How many objects the loaded manifest places. */
count() { return manifest ? manifest.objects.asset.length : 0; },
stats() {

View File

@@ -417,7 +417,7 @@ function showTerrain() {
}
return textures.get(id);
};
const built = buildTerrainChunks(terrainData, loadTexture, sun.position.clone().normalize());
const built = buildTerrainChunks(terrainData, loadTexture, sun.position.clone().normalize(), scenery.gameLights());
// A flat plane far from every object is a placeholder the game never shows: leave it out, and don't put heat
// cells or players on it
const terrainBox = new THREE.Box3().setFromObject(built.group);

View File

@@ -357,7 +357,7 @@ async function showTerrain() {
chunkTerrain = t && t.chunks ? t : null;
}
if (chunkTerrain) {
viewer.setTerrainChunks(chunkTerrain, (id) => assetApi + '/terrain_textures/' + id);
viewer.setTerrainChunks(chunkTerrain, (id) => assetApi + '/terrain_textures/' + id, scenery.gameLights());
return;
}
if (terrainData === undefined) {