From fb5008642bf5ada0f99b1f6394e70d4f3c0fb0af Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 14:15:36 +0000 Subject: [PATCH] [Reviewed][3D terrain] Make the ground faster to draw on slower graphics cards (v0.2.0) - Read the noise making layer borders irregular from a small texture instead of computing 16 hashes per pixel. - Compute the average color of layer textures once on the CPU for the height blending, instead of 4 texture reads per pixel (the mipmap is still read if the image can't be read, like for images from another domain). - Compile the shader only with the layers, normal maps, height blending and texture variation used by the terrain. - Get the world normal of vertices without inverting a matrix per vertex. - Add an advanced "Anisotropic filtering" property (4x by default, as before). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GFZnZwjYz8dSQxu3B2QVUJ --- extensions/reviewed/Terrain3D.json | 393 ++++++++++++++++++++--------- 1 file changed, 280 insertions(+), 113 deletions(-) diff --git a/extensions/reviewed/Terrain3D.json b/extensions/reviewed/Terrain3D.json index 15dbef974..a6b15bef5 100644 --- a/extensions/reviewed/Terrain3D.json +++ b/extensions/reviewed/Terrain3D.json @@ -10,7 +10,7 @@ "name": "Terrain3D", "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", "shortDescription": "A ground with hills and valleys that can be sculpted and painted in the editor, with collisions.", - "version": "0.1.0", + "version": "0.2.0", "description": [ "A 3D terrain object for open worlds, adventure or racing games: hills, mountains, islands or valleys, with painted layers of grass, dirt, rock or sand.", "", @@ -1108,8 +1108,17 @@ "vTerrainLocalXY = position.xy;", "vTerrainLocalZ = position.z;", "vTerrainWorldXY = (modelMatrix * vec4(transformed, 1.0)).xy * terrainWorldScale;", - "vTerrainUp = normalize(transpose(inverse(mat3(modelMatrix))) * objectNormal).z;", + "// The world normal, from the camera space normal (computed by Three.js with a", + "// matrix inverted on the CPU), instead of inverting a matrix for each vertex.", + "vTerrainUp = inverseTransformDirection(transformedNormal, viewMatrix).z;", "`;", + "/** @param {(layer: number) => string} getLayerCode */", + "const forEachLayerCode = (getLayerCode) => [0, 1, 2, 3].map(getLayerCode).join('\\n');", + "", + "// Noise is read from a texture of random values, repeated every", + "// NOISE_MAP_SIZE units of noise: 1 texture read instead of 16 hashes.", + "const NOISE_MAP_SIZE = 256;", + "", "const fragmentShaderDeclarations = `", "uniform sampler2D terrainSplatMap;", "uniform vec2 terrainSplatUvScaleAndOffset;", @@ -1118,11 +1127,11 @@ "uniform sampler2D terrainLayerMap1;", "uniform sampler2D terrainLayerMap2;", "uniform sampler2D terrainLayerMap3;", + "uniform vec3 terrainLayerAverageColors[4];", "uniform sampler2D terrainLayerNormalMap0;", "uniform sampler2D terrainLayerNormalMap1;", "uniform sampler2D terrainLayerNormalMap2;", "uniform sampler2D terrainLayerNormalMap3;", - "uniform float terrainHasNormalMaps;", "uniform float terrainNormalMapStrength;", "uniform float terrainTextureSize;", "uniform float terrainHeightBlending;", @@ -1131,15 +1140,13 @@ "uniform vec2 terrainCliffCosines;", "uniform vec4 terrainBrush;", "uniform sampler2D terrainColorMap;", + "uniform sampler2D terrainNoiseMap;", "uniform vec3 terrainWetShore;", "varying vec2 vTerrainLocalXY;", "varying float vTerrainLocalZ;", "varying vec2 vTerrainWorldXY;", "varying float vTerrainUp;", "", - "float terrainHash(vec2 p) {", - " return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);", - "}", "// The rotation of the copies of textures used to make repetition less visible.", "const mat2 terrainCopyRotation = mat2(0.8, -0.6, 0.6, 0.8);", "/** A bump of a normal map (from -1 to 1), mixed with its rotated copy in patches. */", @@ -1151,15 +1158,16 @@ " copyBump.xy = transpose(terrainCopyRotation) * copyBump.xy;", " return mix(bump, copyBump, patches);", "}", - "float terrainNoise(vec2 p) {", + "/**", + " * 4 independent value noises (one by channel), from 0 to 1, smoothly varying", + " * between random values at integer positions.", + " */", + "vec4 terrainNoise(vec2 p) {", " vec2 i = floor(p);", - " vec2 f = fract(p);", - " vec2 u = f * f * (3.0 - 2.0 * f);", - " return mix(", - " mix(terrainHash(i), terrainHash(i + vec2(1.0, 0.0)), u.x),", - " mix(terrainHash(i + vec2(0.0, 1.0)), terrainHash(i + vec2(1.0, 1.0)), u.x),", - " u.y", - " );", + " vec2 f = p - i;", + " // The read position is moved so that the linear interpolation of the", + " // texture between 2 random values becomes a smooth one.", + " return texture2D(terrainNoiseMap, (i + f * f * (3.0 - 2.0 * f) + 0.5) / ${NOISE_MAP_SIZE}.0);", "}", "`;", "// Holes are samples without any painted layer: the interpolated sum of the", @@ -1177,15 +1185,11 @@ "varying vec2 vTerrainLocalXY;", "`;", "", + "// Layers without texture, normal map, height blending or texture variation", + "// are compiled out (see TerrainRenderer.updateMaterial).", "const fragmentShaderCode = holeShaderCode + `", "// Borders between layers are made irregular and sharper, to look painted.", - "vec2 terrainNoisePosition = vTerrainWorldXY / 24.0;", - "vec4 terrainJitter = vec4(", - " terrainNoise(terrainNoisePosition),", - " terrainNoise(terrainNoisePosition + 19.1),", - " terrainNoise(terrainNoisePosition + 37.7),", - " terrainNoise(terrainNoisePosition + 53.3)", - ");", + "vec4 terrainJitter = terrainNoise(vTerrainWorldXY / 24.0);", "// Layer weights are in a texture, so that painted details stay the same", "// whatever the level of detail of the ground.", "vec2 terrainSplatUv = vTerrainLocalXY * terrainSplatUvScaleAndOffset.x + terrainSplatUvScaleAndOffset.y;", @@ -1196,48 +1200,61 @@ "terrainWeights = mix(terrainWeights, terrainCliffLayer, terrainCliff * dot(terrainCliffLayer, vec4(1.0)));", "", "vec2 terrainUv = vTerrainWorldXY / terrainTextureSize;", - "vec3 terrainTexture0 = texture2D(terrainLayerMap0, terrainUv).rgb;", - "vec3 terrainTexture1 = texture2D(terrainLayerMap1, terrainUv).rgb;", - "vec3 terrainTexture2 = texture2D(terrainLayerMap2, terrainUv).rgb;", - "vec3 terrainTexture3 = texture2D(terrainLayerMap3, terrainUv).rgb;", + "${forEachLayerCode(", + " (layer) => `#ifdef TERRAIN_LAYER_MAP_${layer}", + "vec3 terrainTexture${layer} = texture2D(terrainLayerMap${layer}, terrainUv).rgb;", + "#else", + "vec3 terrainTexture${layer} = vec3(1.0);", + "#endif`", + ")}", "", "// Less visible repetition: in irregular patches, textures are replaced by a", "// rotated and shifted copy of themselves, and colors slowly vary.", "float terrainPatches = 0.0;", "vec2 terrainUv2 = terrainUv;", "float terrainColorVariation = 1.0;", - "if (terrainTextureVariation > 0.0) {", - " // Up to half of the ground is covered by the copies, each patch showing", - " // only one of them (mixing them would make them blurry).", - " float terrainPatchThreshold = 1.0 - 0.5 * terrainTextureVariation;", - " terrainPatches = smoothstep(terrainPatchThreshold - 0.05, terrainPatchThreshold + 0.05, terrainNoise(terrainUv * 0.23 + 7.3));", - " terrainUv2 = terrainCopyRotation * terrainUv + vec2(0.31, 0.73);", - " terrainTexture0 = mix(terrainTexture0, texture2D(terrainLayerMap0, terrainUv2).rgb, terrainPatches);", - " terrainTexture1 = mix(terrainTexture1, texture2D(terrainLayerMap1, terrainUv2).rgb, terrainPatches);", - " terrainTexture2 = mix(terrainTexture2, texture2D(terrainLayerMap2, terrainUv2).rgb, terrainPatches);", - " terrainTexture3 = mix(terrainTexture3, texture2D(terrainLayerMap3, terrainUv2).rgb, terrainPatches);", - " terrainColorVariation += terrainTextureVariation * 0.3 *", - " (terrainNoise(terrainUv * 0.11) + 0.5 * terrainNoise(terrainUv * 0.37 + 3.1) - 0.75);", - "}", + "#ifdef TERRAIN_TEXTURE_VARIATION", + "// Up to half of the ground is covered by the copies, each patch showing", + "// only one of them (mixing them would make them blurry).", + "float terrainPatchThreshold = 1.0 - 0.5 * terrainTextureVariation;", + "terrainPatches = smoothstep(terrainPatchThreshold - 0.05, terrainPatchThreshold + 0.05, terrainNoise(terrainUv * 0.23 + 7.3).x);", + "terrainUv2 = terrainCopyRotation * terrainUv + vec2(0.31, 0.73);", + "${forEachLayerCode(", + " (layer) => `#ifdef TERRAIN_LAYER_MAP_${layer}", + "terrainTexture${layer} = mix(terrainTexture${layer}, texture2D(terrainLayerMap${layer}, terrainUv2).rgb, terrainPatches);", + "#endif`", + ")}", + "terrainColorVariation += terrainTextureVariation * 0.3 *", + " (terrainNoise(terrainUv * 0.11).y + 0.5 * terrainNoise(terrainUv * 0.37 + 3.1).z - 0.75);", + "#endif", "", "// Height-based blending: the brightness of a texture, compared to its", "// average, is used as its height. Where layers meet, the high parts (stones,", "// tufts) cover the low parts (sand, gaps) instead of fading into each other.", - "if (terrainHeightBlending > 0.0) {", - " const vec3 terrainLuminance = vec3(0.299, 0.587, 0.114);", - " vec4 terrainHeights = vec4(", - " dot(terrainTexture0 - textureLod(terrainLayerMap0, vec2(0.5), 16.0).rgb, terrainLuminance),", - " dot(terrainTexture1 - textureLod(terrainLayerMap1, vec2(0.5), 16.0).rgb, terrainLuminance),", - " dot(terrainTexture2 - textureLod(terrainLayerMap2, vec2(0.5), 16.0).rgb, terrainLuminance),", - " dot(terrainTexture3 - textureLod(terrainLayerMap3, vec2(0.5), 16.0).rgb, terrainLuminance)", - " );", - " vec4 terrainHeightWeights = terrainWeights + terrainHeights;", - " float terrainTop = max(max(terrainHeightWeights.x, terrainHeightWeights.y), max(terrainHeightWeights.z, terrainHeightWeights.w));", - " // Only painted layers (and the cliff layer) can show.", - " vec4 terrainHeightBlend = max(terrainHeightWeights - terrainTop + 0.1, 0.0) * step(0.004, terrainWeights);", - " terrainHeightBlend /= max(dot(terrainHeightBlend, vec4(1.0)), 0.00001);", - " terrainWeights = mix(terrainWeights, terrainHeightBlend, terrainHeightBlending);", - "}", + "#ifdef TERRAIN_HEIGHT_BLENDING", + "// Averages are computed once on the CPU, or read from the smallest mipmap", + "// when the image can't be read by the CPU.", + "${forEachLayerCode(", + " (layer) => `#ifdef TERRAIN_LAYER_MIPMAP_AVERAGE_${layer}", + "vec3 terrainAverage${layer} = textureLod(terrainLayerMap${layer}, vec2(0.5), 16.0).rgb;", + "#else", + "vec3 terrainAverage${layer} = terrainLayerAverageColors[${layer}];", + "#endif`", + ")}", + "const vec3 terrainLuminance = vec3(0.299, 0.587, 0.114);", + "vec4 terrainHeights = vec4(", + " dot(terrainTexture0 - terrainAverage0, terrainLuminance),", + " dot(terrainTexture1 - terrainAverage1, terrainLuminance),", + " dot(terrainTexture2 - terrainAverage2, terrainLuminance),", + " dot(terrainTexture3 - terrainAverage3, terrainLuminance)", + ");", + "vec4 terrainHeightWeights = terrainWeights + terrainHeights;", + "float terrainTop = max(max(terrainHeightWeights.x, terrainHeightWeights.y), max(terrainHeightWeights.z, terrainHeightWeights.w));", + "// Only painted layers (and the cliff layer) can show.", + "vec4 terrainHeightBlend = max(terrainHeightWeights - terrainTop + 0.1, 0.0) * step(0.004, terrainWeights);", + "terrainHeightBlend /= max(dot(terrainHeightBlend, vec4(1.0)), 0.00001);", + "terrainWeights = mix(terrainWeights, terrainHeightBlend, terrainHeightBlending);", + "#endif", "", "vec3 terrainColor = terrainColorVariation * (", " terrainWeights.x * terrainLayerColors[0] * terrainTexture0 +", @@ -1269,26 +1286,30 @@ "`;", "", "const fragmentShaderNormalCode = `", - "if (terrainHasNormalMaps > 0.5) {", - " vec3 terrainMapNormal =", - " terrainWeights.x * terrainSampleNormal(terrainLayerNormalMap0, terrainUv, terrainUv2, terrainPatches) +", - " terrainWeights.y * terrainSampleNormal(terrainLayerNormalMap1, terrainUv, terrainUv2, terrainPatches) +", - " terrainWeights.z * terrainSampleNormal(terrainLayerNormalMap2, terrainUv, terrainUv2, terrainPatches) +", - " terrainWeights.w * terrainSampleNormal(terrainLayerNormalMap3, terrainUv, terrainUv2, terrainPatches);", - " terrainMapNormal.xy *= terrainNormalMapStrength;", - " // The X and Y of textures follow the X and Y of the 3D world, projected on", - " // the ground (the camera space of the normal is used).", - " vec3 terrainTangent = (viewMatrix * vec4(1.0, 0.0, 0.0, 0.0)).xyz;", + "#ifdef TERRAIN_NORMAL_MAPS", + "// Layers without normal map are flat.", + "vec3 terrainMapNormal = vec3(0.0);", + "${forEachLayerCode(", + " (layer) => `#ifdef TERRAIN_LAYER_NORMAL_MAP_${layer}", + "terrainMapNormal += terrainWeights[${layer}] * terrainSampleNormal(terrainLayerNormalMap${layer}, terrainUv, terrainUv2, terrainPatches);", + "#else", + "terrainMapNormal.z += terrainWeights[${layer}];", + "#endif`", + ")}", + "terrainMapNormal.xy *= terrainNormalMapStrength;", + "// The X and Y of textures follow the X and Y of the 3D world, projected on", + "// the ground (the camera space of the normal is used).", + "vec3 terrainTangent = (viewMatrix * vec4(1.0, 0.0, 0.0, 0.0)).xyz;", + "terrainTangent -= normal * dot(normal, terrainTangent);", + "// A ground facing the X axis (a rotated terrain) uses the Y axis instead.", + "if (dot(terrainTangent, terrainTangent) < 0.000001) {", + " terrainTangent = (viewMatrix * vec4(0.0, 1.0, 0.0, 0.0)).xyz;", " terrainTangent -= normal * dot(normal, terrainTangent);", - " // A ground facing the X axis (a rotated terrain) uses the Y axis instead.", - " if (dot(terrainTangent, terrainTangent) < 0.000001) {", - " terrainTangent = (viewMatrix * vec4(0.0, 1.0, 0.0, 0.0)).xyz;", - " terrainTangent -= normal * dot(normal, terrainTangent);", - " }", - " terrainTangent = normalize(terrainTangent);", - " vec3 terrainBitangent = cross(normal, terrainTangent);", - " normal = normalize(mat3(terrainTangent, terrainBitangent, normal) * terrainMapNormal);", "}", + "terrainTangent = normalize(terrainTangent);", + "vec3 terrainBitangent = cross(normal, terrainTangent);", + "normal = normalize(mat3(terrainTangent, terrainBitangent, normal) * terrainMapNormal);", + "#endif", "`;", "", "let whiteTexture = null;", @@ -1309,7 +1330,30 @@ " return flatNormalTexture;", "};", "", + "let noiseTexture = null;", + "/** Random values, in 4 independent channels, read by `terrainNoise` in the shader. */", + "const getNoiseTexture = () => {", + " if (!noiseTexture) {", + " const random = createRandom(1);", + " const bytes = new Uint8Array(NOISE_MAP_SIZE * NOISE_MAP_SIZE * 4);", + " for (let index = 0; index < bytes.length; index++) {", + " bytes[index] = Math.floor(random() * 256);", + " }", + " noiseTexture = new THREE.DataTexture(bytes, NOISE_MAP_SIZE, NOISE_MAP_SIZE);", + " noiseTexture.wrapS = THREE.RepeatWrapping;", + " noiseTexture.wrapT = THREE.RepeatWrapping;", + " // No mipmaps: the shader moves the read position between samples.", + " noiseTexture.magFilter = THREE.LinearFilter;", + " noiseTexture.minFilter = THREE.LinearFilter;", + " noiseTexture.needsUpdate = true;", + " }", + " return noiseTexture;", + "};", + "", + "const DEFAULT_ANISOTROPY = 4;", + "/** @type {WeakMap>} */", "const layerTextures = new WeakMap();", + "/** @type {WeakMap>} */", "const layerNormalMaps = new WeakMap();", "/**", " * Textures of the image manager have no mipmaps: they would shimmer when", @@ -1317,12 +1361,18 @@ " * @param {gdjs.RuntimeGame} game", " * @param {string} resourceName", " * @param {boolean} isNormalMap Normal maps are directions, not colors.", + " * @param {number} anisotropy", " */", - "const getLayerTexture = (game, resourceName, isNormalMap) => {", + "const getLayerTexture = (game, resourceName, isNormalMap, anisotropy) => {", " if (!resourceName) return isNormalMap ? getFlatNormalTexture() : getWhiteTexture();", " const imageTexture = game.getImageManager().getThreeTexture(resourceName);", - " const textures = isNormalMap ? layerNormalMaps : layerTextures;", - " let texture = textures.get(imageTexture);", + " const texturesByImage = isNormalMap ? layerNormalMaps : layerTextures;", + " let texturesByAnisotropy = texturesByImage.get(imageTexture);", + " if (!texturesByAnisotropy) {", + " texturesByAnisotropy = new Map();", + " texturesByImage.set(imageTexture, texturesByAnisotropy);", + " }", + " let texture = texturesByAnisotropy.get(anisotropy);", " if (!texture) {", " texture = imageTexture.clone();", " if (isNormalMap) texture.colorSpace = THREE.NoColorSpace;", @@ -1330,13 +1380,71 @@ " texture.wrapT = THREE.RepeatWrapping;", " texture.minFilter = THREE.LinearMipmapLinearFilter;", " texture.generateMipmaps = true;", - " texture.anisotropy = 4;", + " texture.anisotropy = anisotropy;", " texture.needsUpdate = true;", - " textures.set(imageTexture, texture);", + " texturesByAnisotropy.set(anisotropy, texture);", " }", " return texture;", "};", "", + "/** @type {Float32Array | null} */", + "let srgbToLinearValues = null;", + "/** @type {WeakMap} */", + "const linearAverageColors = new WeakMap();", + "/**", + " * @param {THREE.Texture} texture", + " * @returns {THREE.Vector3 | null} The average color of the texture, in the", + " * linear color space (like colors read by shaders), or null if the image", + " * can't be read: not loaded yet, or from another domain without CORS.", + " */", + "const getLinearAverageColor = (texture) => {", + " const image = texture.image;", + " if (!image) return null;", + " const cachedColor = linearAverageColors.get(image);", + " if (cachedColor) return cachedColor;", + " const isDrawable =", + " typeof document !== 'undefined' &&", + " ((image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0) ||", + " image instanceof HTMLCanvasElement ||", + " (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap));", + " if (!isDrawable) return null;", + " const size = 64;", + " let pixels;", + " try {", + " const canvas = document.createElement('canvas');", + " canvas.width = size;", + " canvas.height = size;", + " const context = canvas.getContext('2d');", + " // Downscaled with filtering, so that all pixels are counted.", + " context.imageSmoothingEnabled = true;", + " context.imageSmoothingQuality = 'high';", + " context.drawImage(image, 0, 0, size, size);", + " pixels = context.getImageData(0, 0, size, size).data;", + " } catch (error) {", + " return null;", + " }", + " if (!srgbToLinearValues) {", + " srgbToLinearValues = new Float32Array(256);", + " for (let value = 0; value < 256; value++) {", + " const srgb = value / 255;", + " srgbToLinearValues[value] = srgb <= 0.04045 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4);", + " }", + " }", + " const isSrgb = texture.colorSpace === THREE.SRGBColorSpace;", + " let red = 0;", + " let green = 0;", + " let blue = 0;", + " for (let index = 0; index < pixels.length; index += 4) {", + " red += isSrgb ? srgbToLinearValues[pixels[index]] : pixels[index] / 255;", + " green += isSrgb ? srgbToLinearValues[pixels[index + 1]] : pixels[index + 1] / 255;", + " blue += isSrgb ? srgbToLinearValues[pixels[index + 2]] : pixels[index + 2] / 255;", + " }", + " const count = size * size;", + " const color = new THREE.Vector3(red / count, green / count, blue / count);", + " linearAverageColors.set(image, color);", + " return color;", + "};", + "", "/**", " * A texture of 4 bytes per sample of a grid of `size * size` samples (layer", " * weights, tint and wetness...), interpolated between samples.", @@ -1395,11 +1503,14 @@ " terrainLayerMap1: { value: getWhiteTexture() },", " terrainLayerMap2: { value: getWhiteTexture() },", " terrainLayerMap3: { value: getWhiteTexture() },", + " // Used by the height blending, in the linear color space.", + " terrainLayerAverageColors: {", + " value: [new THREE.Vector3(1, 1, 1), new THREE.Vector3(1, 1, 1), new THREE.Vector3(1, 1, 1), new THREE.Vector3(1, 1, 1)],", + " },", " terrainLayerNormalMap0: { value: getFlatNormalTexture() },", " terrainLayerNormalMap1: { value: getFlatNormalTexture() },", " terrainLayerNormalMap2: { value: getFlatNormalTexture() },", " terrainLayerNormalMap3: { value: getFlatNormalTexture() },", - " terrainHasNormalMaps: { value: 0 },", " terrainNormalMapStrength: { value: 1 },", " terrainTextureSize: { value: 256 },", " terrainHeightBlending: { value: 0 },", @@ -1408,6 +1519,7 @@ " terrainCliffCosines: { value: new THREE.Vector2() },", " terrainBrush: { value: new THREE.Vector4() },", " terrainColorMap: { value: getWhiteTexture() },", + " terrainNoiseMap: { value: getNoiseTexture() },", " // Z (in the terrain group) under which the ground is wet, fade height, and 1 if it's used.", " terrainWetShore: { value: new THREE.Vector3() },", " // Scene units by 3D world unit.", @@ -1427,8 +1539,12 @@ " ['#include ', '#include \\n' + fragmentShaderNormalCode],", " ], 'Terrain3D');", " };", - " this.material.customProgramCacheKey = () => (this.hasHoles ? 'Terrain3DWithHoles' : 'Terrain3D');", " this.hasHoles = false;", + " /** The defines of the features used by the terrain (see `updateMaterial`), without holes. */", + " this.featureDefines = [];", + " /** All the defines of the shader, separated by semicolons. */", + " this.shaderKey = '';", + " this.material.customProgramCacheKey = () => 'Terrain3D;' + this.shaderKey;", " // Without them, holes would cast shadows.", " this.depthMaterial = this._createHoleDepthMaterial(new THREE.MeshDepthMaterial());", " this.distanceMaterial = this._createHoleDepthMaterial(new THREE.MeshDistanceMaterial());", @@ -1465,11 +1581,28 @@ " setHasHoles(hasHoles) {", " if (hasHoles === this.hasHoles) return;", " this.hasHoles = hasHoles;", + " this._updateShaderDefines();", + " for (const chunk of this.chunks) this._setChunkShadowMaterials(chunk.mesh);", + " }", + "", + " /**", + " * Compile the shader only with the features used by the terrain. It is", + " * compiled again only when they change: properties only change in the", + " * editor, holes can be made by actions.", + " */", + " _updateShaderDefines() {", + " const defines = this.hasHoles ? [...this.featureDefines, 'TERRAIN_HOLES'] : this.featureDefines;", + " const shaderKey = defines.join(';');", + " if (shaderKey === this.shaderKey) return;", " // The material has its own defines (STANDARD).", - " if (hasHoles) this.material.defines.TERRAIN_HOLES = '';", - " else delete this.material.defines.TERRAIN_HOLES;", + " for (const name of this.shaderKey ? this.shaderKey.split(';') : []) {", + " delete this.material.defines[name];", + " }", + " for (const name of defines) {", + " this.material.defines[name] = '';", + " }", + " this.shaderKey = shaderKey;", " this.material.needsUpdate = true;", - " for (const chunk of this.chunks) this._setChunkShadowMaterials(chunk.mesh);", " }", "", " /** @param {THREE.Mesh} mesh */", @@ -1701,16 +1834,38 @@ " object._getLayer3NormalMap(),", " object._getLayer4NormalMap(),", " ];", + " const anisotropy = Math.max(parseInt(object._getAnisotropy(), 10) || DEFAULT_ANISOTROPY, 1);", + " const heightBlending = clamp01(object._getHeightBlending());", + " const textureVariation = clamp01(object._getTextureVariation());", + " const featureDefines = [];", " for (let layer = 0; layer < LAYER_COUNT; layer++) {", " this.uniforms.terrainLayerColors.value[layer].set(gdjs.rgbOrHexStringToNumber(colors[layer]));", - " this.uniforms['terrainLayerMap' + layer].value = getLayerTexture(game, textures[layer], false);", - " this.uniforms['terrainLayerNormalMap' + layer].value = getLayerTexture(game, normalMaps[layer], true);", + " const layerTexture = getLayerTexture(game, textures[layer], false, anisotropy);", + " this.uniforms['terrainLayerMap' + layer].value = layerTexture;", + " this.uniforms['terrainLayerNormalMap' + layer].value = getLayerTexture(game, normalMaps[layer], true, anisotropy);", + " const averageColor = this.uniforms.terrainLayerAverageColors.value[layer];", + " if (textures[layer]) {", + " featureDefines.push('TERRAIN_LAYER_MAP_' + layer);", + " const linearAverageColor = heightBlending > 0 ? getLinearAverageColor(layerTexture) : null;", + " if (linearAverageColor) {", + " averageColor.copy(linearAverageColor);", + " } else if (heightBlending > 0) {", + " featureDefines.push('TERRAIN_LAYER_MIPMAP_AVERAGE_' + layer);", + " }", + " } else {", + " averageColor.set(1, 1, 1);", + " }", + " if (normalMaps[layer]) featureDefines.push('TERRAIN_LAYER_NORMAL_MAP_' + layer);", " }", - " this.uniforms.terrainHasNormalMaps.value = normalMaps.some((normalMap) => !!normalMap) ? 1 : 0;", + " if (normalMaps.some((normalMap) => !!normalMap)) featureDefines.push('TERRAIN_NORMAL_MAPS');", + " if (heightBlending > 0) featureDefines.push('TERRAIN_HEIGHT_BLENDING');", + " if (textureVariation > 0) featureDefines.push('TERRAIN_TEXTURE_VARIATION');", + " this.featureDefines = featureDefines;", + " this._updateShaderDefines();", " this.uniforms.terrainNormalMapStrength.value = Math.max(object._getNormalMapStrength(), 0);", " this.uniforms.terrainTextureSize.value = Math.max(object._getTextureSize(), 1);", - " this.uniforms.terrainHeightBlending.value = clamp01(object._getHeightBlending());", - " this.uniforms.terrainTextureVariation.value = clamp01(object._getTextureVariation());", + " this.uniforms.terrainHeightBlending.value = heightBlending;", + " this.uniforms.terrainTextureVariation.value = textureVariation;", "", " const cliffLayer = parseInt(object._getCliffLayer(), 10);", " const cliffAngle = gdjs.toRad(object._getCliffAngle());", @@ -2628,40 +2783,21 @@ " return typeof scene.getRenderer3DWorldScale === 'function' ? scene.getRenderer3DWorldScale() : 1;", "};", "", + "/** @type {WeakMap} */", "const averageColors = new WeakMap();", "let white = null;", "/**", " * @param {THREE.Texture} texture", - " * @returns {THREE.Color} The average color of the texture (white when it's not loaded).", + " * @returns {THREE.Color} The average color of the texture (white when it can't be read).", " */", "const getAverageColor = (texture) => {", - " const cachedColor = averageColors.get(texture);", - " if (cachedColor) return cachedColor;", - " const image = texture.image;", - " const isDrawable =", - " typeof document !== 'undefined' &&", - " ((image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0) ||", - " image instanceof HTMLCanvasElement ||", - " (typeof ImageBitmap !== 'undefined' && image instanceof ImageBitmap));", - " if (!isDrawable) return white || (white = new THREE.Color(1, 1, 1));", - " const size = 8;", - " const canvas = document.createElement('canvas');", - " canvas.width = size;", - " canvas.height = size;", - " const context = canvas.getContext('2d');", - " context.drawImage(image, 0, 0, size, size);", - " const pixels = context.getImageData(0, 0, size, size).data;", - " let red = 0;", - " let green = 0;", - " let blue = 0;", - " for (let index = 0; index < pixels.length; index += 4) {", - " red += pixels[index];", - " green += pixels[index + 1];", - " blue += pixels[index + 2];", + " const linearAverageColor = getLinearAverageColor(texture);", + " if (!linearAverageColor) return white || (white = new THREE.Color(1, 1, 1));", + " let color = averageColors.get(linearAverageColor);", + " if (!color) {", + " color = new THREE.Color(linearAverageColor.x, linearAverageColor.y, linearAverageColor.z);", + " averageColors.set(linearAverageColor, color);", " }", - " const count = size * size * 255;", - " const color = new THREE.Color().setRGB(red / count, green / count, blue / count, THREE.SRGBColorSpace);", - " averageColors.set(texture, color);", " return color;", "};", "", @@ -6149,6 +6285,37 @@ "group": "Rendering", "name": "ReceiveShadow" }, + { + "value": "4", + "type": "Choice", + "label": "Anisotropic filtering", + "description": "Keeps layer textures sharp where the ground is seen at a low angle (like from a car). Lower is faster on slow graphics cards, but blurs the ground in the distance.", + "group": "Rendering", + "choices": [ + { + "label": "None (fastest)", + "value": "1" + }, + { + "label": "2x", + "value": "2" + }, + { + "label": "4x", + "value": "4" + }, + { + "label": "8x", + "value": "8" + }, + { + "label": "16x (sharpest)", + "value": "16" + } + ], + "advanced": true, + "name": "Anisotropy" + }, { "value": "", "type": "String",