/** * Terrain material shader modifications for MeshLambertMaterial. * * Matches Torque's terrain rendering formula (terrLighting.cc + blender.cc): * output = clamp(lighting × texture, 0, 1) * * Where: * - lighting = clamp(ambient + NdotL × shadowFactor × sunColor, 0, 1) * - NdotL and terrain self-shadows from pre-computed lightmap (ray-traced) * - shadowFactor from Three.js real-time shadow maps (for building/object shadows) * - All operations in sRGB/gamma space * * Key insights from Torque source (terrLighting.cc:471-483): * 1. Lightmap bakes: ambient + max(0, N·L) × sunColor for lit areas * 2. Shadowed areas get only ambient * 3. Mission sun/ambient colors ARE sRGB values - Torque used them directly * 4. Final output = lightmap × texture, all in gamma space */ import { globalSunUniforms } from "./globalSunUniforms"; // Terrain and texture dimensions (must match TerrainBlock.tsx constants) const TERRAIN_SIZE = 256; // Terrain grid size in squares const LIGHTMAP_SIZE = 512; // Lightmap texture size (2 pixels per terrain square) // Detail texture tiling factor. const DETAIL_TILING = 64.0; // Distance at which detail texture fully fades out (in world units) const DETAIL_FADE_DISTANCE = 150.0; // sRGB <-> Linear conversion functions (GLSL) const colorSpaceFunctions = /* glsl */ ` vec3 terrainLinearToSRGB(vec3 linear) { vec3 higher = pow(linear, vec3(1.0/2.4)) * 1.055 - 0.055; vec3 lower = linear * 12.92; return mix(lower, higher, step(vec3(0.0031308), linear)); } vec3 terrainSRGBToLinear(vec3 srgb) { vec3 higher = pow((srgb + 0.055) / 1.055, vec3(2.4)); vec3 lower = srgb / 12.92; return mix(lower, higher, step(vec3(0.04045), srgb)); } // Debug grid overlay using screen-space derivatives for sharp, anti-aliased lines // Returns 1.0 on grid lines, 0.0 elsewhere float terrainDebugGrid(vec2 uv, float gridSize, float lineWidth) { vec2 scaledUV = uv * gridSize; vec2 grid = abs(fract(scaledUV - 0.5) - 0.5) / fwidth(scaledUV); float line = min(grid.x, grid.y); return 1.0 - min(line / lineWidth, 1.0); } `; export function updateTerrainTextureShader({ shader, baseTextures, alphaTextures, visibilityMask, tiling, detailTexture = null, lightmap = null, }: { shader: any; baseTextures: any[]; alphaTextures: any[]; visibilityMask: any; tiling: Record; detailTexture?: any; lightmap?: any; }) { // Add global sun uniform (shared reference - value updates automatically) shader.uniforms.sunLightPointsDown = globalSunUniforms.sunLightPointsDown; const layerCount = baseTextures.length; baseTextures.forEach((tex, i) => { shader.uniforms[`albedo${i}`] = { value: tex }; }); // Pass all alpha textures including mask0 for additive blending alphaTextures.forEach((tex, i) => { shader.uniforms[`mask${i}`] = { value: tex }; }); // Add visibility mask uniform if we have empty squares if (visibilityMask) { shader.uniforms.visibilityMask = { value: visibilityMask }; } // Add per-texture tiling uniforms baseTextures.forEach((tex, i) => { shader.uniforms[`tiling${i}`] = { value: tiling[i] ?? 32, }; }); // Add lightmap uniform for smooth per-pixel terrain lighting if (lightmap) { shader.uniforms.terrainLightmap = { value: lightmap }; } // Add detail texture uniforms if (detailTexture) { shader.uniforms.detailTexture = { value: detailTexture }; shader.uniforms.detailTiling = { value: DETAIL_TILING }; shader.uniforms.detailFadeDistance = { value: DETAIL_FADE_DISTANCE }; // Add vertex shader code to pass world position to fragment shader shader.vertexShader = shader.vertexShader.replace( "#include ", `#include varying vec3 vTerrainWorldPos;`, ); shader.vertexShader = shader.vertexShader.replace( "#include ", `#include vTerrainWorldPos = (modelMatrix * vec4(transformed, 1.0)).xyz;`, ); } // Declare our uniforms and color space functions at the top of the fragment shader shader.fragmentShader = ` uniform sampler2D albedo0; uniform sampler2D albedo1; uniform sampler2D albedo2; uniform sampler2D albedo3; uniform sampler2D albedo4; uniform sampler2D albedo5; uniform sampler2D mask0; uniform sampler2D mask1; uniform sampler2D mask2; uniform sampler2D mask3; uniform sampler2D mask4; uniform sampler2D mask5; uniform float tiling0; uniform float tiling1; uniform float tiling2; uniform float tiling3; uniform float tiling4; uniform float tiling5; ${visibilityMask ? "uniform sampler2D visibilityMask;" : ""} ${lightmap ? "uniform sampler2D terrainLightmap;" : ""} uniform bool sunLightPointsDown; ${ detailTexture ? `uniform sampler2D detailTexture; uniform float detailTiling; uniform float detailFadeDistance; varying vec3 vTerrainWorldPos;` : "" } ${colorSpaceFunctions} // Global variable to store shadow factor from RE_Direct for use in output calculation float terrainShadowFactor = 1.0; ` + shader.fragmentShader; if (visibilityMask) { const clippingPlaceholder = "#include "; shader.fragmentShader = shader.fragmentShader.replace( clippingPlaceholder, `${clippingPlaceholder} // Early discard for invisible areas (before fog/lighting) float visibility = texture2D(visibilityMask, vMapUv).r; if (visibility < 0.5) { discard; } `, ); } // Replace the default map sampling block with our layered blend. // We rely on vMapUv provided by USE_MAP. shader.fragmentShader = shader.fragmentShader.replace( "#include ", ` // Sample base albedo layers (sRGB textures auto-decoded to linear by Three.js) vec2 baseUv = vMapUv; vec3 c0 = texture2D(albedo0, baseUv * vec2(tiling0)).rgb; ${ layerCount > 1 ? `vec3 c1 = texture2D(albedo1, baseUv * vec2(tiling1)).rgb;` : "" } ${ layerCount > 2 ? `vec3 c2 = texture2D(albedo2, baseUv * vec2(tiling2)).rgb;` : "" } ${ layerCount > 3 ? `vec3 c3 = texture2D(albedo3, baseUv * vec2(tiling3)).rgb;` : "" } ${ layerCount > 4 ? `vec3 c4 = texture2D(albedo4, baseUv * vec2(tiling4)).rgb;` : "" } ${ layerCount > 5 ? `vec3 c5 = texture2D(albedo5, baseUv * vec2(tiling5)).rgb;` : "" } // Sample alpha masks for all layers (use R channel) // Add +0.5 texel offset: Torque samples alpha at grid corners (integer indices), // but GPU linear filtering samples at texel centers. This offset aligns them. vec2 alphaUv = baseUv + vec2(0.5 / ${TERRAIN_SIZE}.0); float a0 = texture2D(mask0, alphaUv).r; ${layerCount > 1 ? `float a1 = texture2D(mask1, alphaUv).r;` : ""} ${layerCount > 2 ? `float a2 = texture2D(mask2, alphaUv).r;` : ""} ${layerCount > 3 ? `float a3 = texture2D(mask3, alphaUv).r;` : ""} ${layerCount > 4 ? `float a4 = texture2D(mask4, alphaUv).r;` : ""} ${layerCount > 5 ? `float a5 = texture2D(mask5, alphaUv).r;` : ""} // Torque-style additive weighted blending (blender.cc): // result = tex0 * alpha0 + tex1 * alpha1 + tex2 * alpha2 + ... // Each layer's alpha map defines its contribution weight. vec3 blended = c0 * a0; ${layerCount > 1 ? `blended += c1 * a1;` : ""} ${layerCount > 2 ? `blended += c2 * a2;` : ""} ${layerCount > 3 ? `blended += c3 * a3;` : ""} ${layerCount > 4 ? `blended += c4 * a4;` : ""} ${layerCount > 5 ? `blended += c5 * a5;` : ""} // Assign to diffuseColor before lighting vec3 textureColor = blended; ${ detailTexture ? `// Detail texture blending (Torque-style multiplicative blend) // Sample detail texture at high frequency tiling vec3 detailColor = texture2D(detailTexture, baseUv * detailTiling).rgb; // Calculate distance-based fade factor using world positions // Torque: distFactor = (zeroDetailDistance - distance) / zeroDetailDistance float distToCamera = distance(vTerrainWorldPos, cameraPosition); float detailFade = clamp(1.0 - distToCamera / detailFadeDistance, 0.0, 1.0); // Torque blending: dst * lerp(1.0, detailTexel, fadeFactor) // Detail textures are authored with bright values (~0.8 mean), not 0.5 gray // Direct multiplication adds subtle darkening for surface detail textureColor *= mix(vec3(1.0), detailColor, detailFade);` : "" } // Store blended texture in diffuseColor (still in linear space here) // We'll convert to sRGB in the output calculation diffuseColor.rgb = textureColor; `, ); // When lightmap is available, override RE_Direct to extract shadow factor // We don't compute lighting here - just capture the shadow for use in output if (lightmap) { shader.fragmentShader = shader.fragmentShader.replace( "#include ", `#include // Override RE_Direct to extract shadow factor for Torque-style gamma-space lighting #undef RE_Direct void RE_Direct_TerrainShadow( const in IncidentLight directLight, const in vec3 geometryPosition, const in vec3 geometryNormal, const in vec3 geometryViewDir, const in vec3 geometryClearcoatNormal, const in LambertMaterial material, inout ReflectedLight reflectedLight ) { // Torque lighting (terrLighting.cc): if light points up, terrain gets only ambient // This prevents shadow acne from light hitting terrain backfaces if (!sunLightPointsDown) { terrainShadowFactor = 0.0; return; } // directLight.color = sunColor * shadowFactor (shadow already applied by Three.js) // Extract shadow factor by comparing to original sun color #if ( NUM_DIR_LIGHTS > 0 ) vec3 originalSunColor = directionalLights[0].color; float sunMax = max(max(originalSunColor.r, originalSunColor.g), originalSunColor.b); float shadowedMax = max(max(directLight.color.r, directLight.color.g), directLight.color.b); terrainShadowFactor = clamp(shadowedMax / max(sunMax, 0.001), 0.0, 1.0); #endif // Don't add to reflectedLight - we'll compute lighting in gamma space at output } #define RE_Direct RE_Direct_TerrainShadow `, ); // Override lights_fragment_begin to skip indirect diffuse calculation // We'll handle ambient in gamma space shader.fragmentShader = shader.fragmentShader.replace( "#include ", `#include // Clear indirect diffuse - we'll compute ambient in gamma space #if defined( RE_IndirectDiffuse ) irradiance = vec3(0.0); #endif `, ); // Clear the indirect diffuse after lights_fragment_end shader.fragmentShader = shader.fragmentShader.replace( "#include ", `#include // Clear Three.js lighting - we compute everything in gamma space reflectedLight.directDiffuse = vec3(0.0); reflectedLight.indirectDiffuse = vec3(0.0); `, ); } // Replace opaque_fragment with Torque-style gamma-space calculation shader.fragmentShader = shader.fragmentShader.replace( "#include ", `// Torque-style terrain lighting: output = clamp(lighting × texture, 0, 1) in sRGB space { // Get texture in sRGB space (undo Three.js linear decode) vec3 textureSRGB = terrainLinearToSRGB(diffuseColor.rgb); ${ lightmap ? ` // Sample terrain lightmap for smooth NdotL vec2 lightmapUv = vMapUv + vec2(0.5 / ${LIGHTMAP_SIZE}.0); float lightmapNdotL = texture2D(terrainLightmap, lightmapUv).r; // Get sun and ambient colors from Three.js lights (these ARE sRGB values from mission file) // Three.js interprets them as linear, but the numerical values are preserved #if ( NUM_DIR_LIGHTS > 0 ) vec3 sunColorSRGB = directionalLights[0].color; #else vec3 sunColorSRGB = vec3(0.7); #endif vec3 ambientColorSRGB = ambientLightColor; // Torque formula (terrLighting.cc:471-483): // lighting = ambient + NdotL * shadowFactor * sunColor // Clamp lighting to [0,1] before multiplying by texture vec3 lightingSRGB = clamp(ambientColorSRGB + lightmapNdotL * terrainShadowFactor * sunColorSRGB, 0.0, 1.0); ` : ` // No lightmap - use simple ambient lighting vec3 lightingSRGB = ambientLightColor; ` } // Torque formula: output = clamp(lighting × texture, 0, 1) in sRGB/gamma space vec3 resultSRGB = clamp(lightingSRGB * textureSRGB, 0.0, 1.0); // Convert back to linear for Three.js output pipeline outgoingLight = terrainSRGBToLinear(resultSRGB) + totalEmissiveRadiance; } #include `, ); // Add debug grid overlay AFTER opaque_fragment sets gl_FragColor // Uses #if so material.defines.DEBUG_MODE (0 or 1) can trigger recompilation shader.fragmentShader = shader.fragmentShader.replace( "#include ", `#if DEBUG_MODE // Debug mode: overlay green grid matching terrain grid squares (256x256) float gridIntensity = terrainDebugGrid(vMapUv, 256.0, 1.5); vec3 gridColor = vec3(0.0, 0.8, 0.4); // Green gl_FragColor.rgb = mix(gl_FragColor.rgb, gridColor, gridIntensity * 0.1); #endif #include `, ); }