From 90614782bc6552e5b38fbd97115feb4e5a6b8966 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 15:40:26 +0000 Subject: [PATCH] Upgrade the 3D terrain extension to 0.3.1 in the examples using it - Upgrade Terrain3D (0.1.0/0.2.0 -> 0.3.1) in the 26 examples using it, as serialized by GDevelop when importing the extension. - Keep the grass tufts of the RTS and top-down RPG starters on the ground: their model origin is at mid-height, so "Point put on the ground" is set to "Center of the model base" (otherwise half of each tuft is buried). - Update the rendering screenshots of the 3D island: the ground is drawn differently since 0.2.0 (the grassy ground looks the same, with small per-pixel differences). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GFZna18e8tggLK2kBPwN7V --- .../3d-island-terrain/3d-island-terrain.json | 2156 ++++++++++++---- .../starting-3D-platformer.json | 558 +++- .../starting-3d-car-racing.json | 558 +++- .../starting-3d-driving.json | 558 +++- .../starting-3d-grand-theft-auto.json | 558 +++- .../starting-3d-tank/starting-3d-tank.json | 558 +++- .../starting-expanded-3D-platformer.json | 2156 ++++++++++++---- .../starting-expanded-3d-car-racing.json | 2238 ++++++++++++----- .../starting-expanded-3d-driving.json | 2238 ++++++++++++----- .../starting-expanded-3d-flight-sim.json | 2224 +++++++++++----- ...starting-expanded-3d-grand-theft-auto.json | 2234 +++++++++++----- ...arting-expanded-3d-rts-unit-selection.json | 2161 ++++++++++++---- .../starting-expanded-3d-tank.json | 2232 +++++++++++----- .../starting-expanded-3d-top-down-rpg.json | 1917 +++++++++++--- ...tarting-expanded-first-person-farming.json | 2152 ++++++++++++---- ...starting-expanded-first-person-horror.json | 2236 +++++++++++----- ...expanded-first-person-shooter-enemies.json | 2234 +++++++++++----- ...-expanded-first-person-shooter-horror.json | 2234 +++++++++++----- ...panded-first-person-survival-crafting.json | 2234 +++++++++++----- .../starting-first-person-farming.json | 558 +++- .../starting-first-person-horror.json | 558 +++- ...starting-first-person-shooter-enemies.json | 558 +++- .../starting-first-person-shooter-horror.json | 558 +++- .../starting-first-person-shooter.json | 558 +++- ...arting-first-person-survival-crafting.json | 558 +++- .../starting-first-person.json | 558 +++- .../the-island-from-above.png | Bin 575397 -> 575246 bytes .../the-view-of-the-player.png | Bin 518936 -> 518783 bytes 28 files changed, 29192 insertions(+), 8150 deletions(-) diff --git a/examples/3d-island-terrain/3d-island-terrain.json b/examples/3d-island-terrain/3d-island-terrain.json index f82fc5f49..870be9350 100644 --- a/examples/3d-island-terrain/3d-island-terrain.json +++ b/examples/3d-island-terrain/3d-island-terrain.json @@ -28880,7 +28880,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.3.1", "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.", "", @@ -28916,14 +28916,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -29978,8 +29986,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;", @@ -29988,11 +30005,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;", @@ -30001,15 +30018,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. */", @@ -30021,15 +30036,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", @@ -30047,15 +30063,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;", @@ -30066,48 +30078,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 +", @@ -30139,26 +30164,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;", @@ -30179,7 +30208,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", @@ -30187,12 +30239,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;", @@ -30200,13 +30258,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.", @@ -30265,11 +30381,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 },", @@ -30278,6 +30397,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.", @@ -30297,8 +30417,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());", @@ -30335,11 +30459,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 */", @@ -30571,16 +30712,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());", @@ -31498,40 +31661,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;", "};", "", @@ -31837,14 +31981,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -32182,18 +32326,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -32425,18 +32569,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -32447,19 +32612,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -32488,17 +32656,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -32525,8 +32702,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -32620,11 +32800,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -32637,7 +32822,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -32665,14 +32851,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -32720,9 +32909,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -32741,6 +32935,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -32853,9 +33048,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -32910,12 +33105,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -33089,8 +33284,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -33387,125 +33585,375 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] } ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, - "keepRatio": true, - "layer": "", + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, + "keepRatio": true, + "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -33555,8 +34003,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -33583,8 +34031,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -33611,8 +34059,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -33639,15 +34087,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -33671,8 +34121,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -33686,9 +34136,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -33696,13 +34146,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -33728,8 +34178,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -33753,9 +34203,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -33763,13 +34213,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -33793,8 +34243,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -33808,9 +34258,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -33818,13 +34268,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -33848,8 +34298,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -33863,9 +34313,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -33873,13 +34323,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -33903,8 +34353,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -33918,9 +34368,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -33928,13 +34378,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -33960,8 +34410,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -33985,9 +34435,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -33995,13 +34445,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -34025,8 +34475,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -34040,9 +34490,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -34050,13 +34500,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -34080,8 +34530,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -34095,9 +34545,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -34105,13 +34555,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -34129,12 +34579,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -34147,16 +34600,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -34174,12 +34624,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -34192,16 +34645,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -34224,8 +34674,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -34243,13 +34693,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -34272,8 +34722,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -34291,13 +34741,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -34323,8 +34773,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -34357,13 +34807,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -34385,8 +34835,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -34399,13 +34849,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -34424,12 +34874,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -34443,21 +34896,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -34479,29 +34929,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -34522,23 +34972,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -34558,8 +35008,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -34567,13 +35017,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -34591,12 +35041,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -34609,16 +35062,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -34636,12 +35086,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -34654,16 +35107,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -34681,12 +35131,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -34699,14 +35152,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -34751,6 +35304,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -34808,6 +35362,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -34819,6 +35374,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -34837,6 +35393,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -34848,6 +35405,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -34866,6 +35424,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -34877,6 +35436,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -34895,6 +35455,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -34906,6 +35467,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -34919,8 +35481,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -35000,8 +35562,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -35019,6 +35581,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", @@ -35027,7 +35620,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -35039,6 +35748,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -35126,16 +35836,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -35200,8 +35909,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -35228,8 +35937,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -35264,8 +35973,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -35292,13 +36001,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -35311,8 +36037,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -35320,8 +36046,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -35380,8 +36106,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -35412,13 +36138,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -35427,10 +36215,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -35440,6 +36229,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -35518,23 +36308,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -35592,8 +36397,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -35620,8 +36425,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -35652,8 +36457,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -35680,15 +36485,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -35711,8 +36518,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -35730,13 +36537,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -35759,8 +36566,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -35778,13 +36585,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -35800,25 +36607,117 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, "parameters": [ { - "description": "Object", - "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -35829,19 +36728,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -35854,18 +36756,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -35875,19 +36774,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -35900,20 +36802,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -35921,19 +36821,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -35946,20 +36849,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -35967,19 +36868,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -35992,33 +36896,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -36119,8 +37119,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -36128,8 +37128,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -36137,8 +37137,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -36154,17 +37154,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -36200,7 +37200,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] }, diff --git a/examples/starting-3D-platformer/starting-3D-platformer.json b/examples/starting-3D-platformer/starting-3D-platformer.json index 2a2afb982..e0cab8426 100644 --- a/examples/starting-3D-platformer/starting-3D-platformer.json +++ b/examples/starting-3D-platformer/starting-3D-platformer.json @@ -27615,7 +27615,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.2.0", + "version": "0.3.1", "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.", "", @@ -27651,6 +27651,12 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], @@ -31055,7 +31061,7 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], @@ -31298,18 +31304,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -31320,19 +31347,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -31361,17 +31391,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -31398,8 +31437,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -31493,11 +31535,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -31510,7 +31557,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -31538,14 +31586,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -31593,9 +31644,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -31614,6 +31670,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -31726,9 +31783,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -31783,12 +31840,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -31962,8 +32019,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -32260,11 +32320,246 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -32278,6 +32573,9 @@ }, { "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" } ] }, @@ -34666,6 +34964,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -34759,7 +35058,23 @@ "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -35152,7 +35467,7 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "expressionType": { @@ -35223,6 +35538,100 @@ } ], "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -35268,6 +35677,12 @@ }, { "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" } ] } @@ -35278,7 +35693,7 @@ "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" @@ -35287,12 +35702,58 @@ "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", "unit": "Pixel", "label": "Height of a copy", - "description": "Copies keep the proportions of the model.", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -35430,7 +35891,7 @@ "type": "Number", "unit": "Pixel", "label": "Trunk diameter", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, @@ -35485,7 +35946,13 @@ "propertyName": "Model" }, { - "propertyName": "Size" + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" }, { "propertyName": "MinScale" @@ -35498,6 +35965,9 @@ }, { "propertyName": "RandomTint" + }, + { + "propertyName": "Size" } ] }, diff --git a/examples/starting-3d-car-racing/starting-3d-car-racing.json b/examples/starting-3d-car-racing/starting-3d-car-racing.json index 066540e30..1bbad5f90 100644 --- a/examples/starting-3d-car-racing/starting-3d-car-racing.json +++ b/examples/starting-3d-car-racing/starting-3d-car-racing.json @@ -13158,7 +13158,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.2.0", + "version": "0.3.1", "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.", "", @@ -13194,6 +13194,12 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], @@ -16598,7 +16604,7 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], @@ -16841,18 +16847,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -16863,19 +16890,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -16904,17 +16934,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -16941,8 +16980,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -17036,11 +17078,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -17053,7 +17100,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -17081,14 +17129,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -17136,9 +17187,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -17157,6 +17213,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -17269,9 +17326,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -17326,12 +17383,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -17505,8 +17562,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -17803,11 +17863,246 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -17821,6 +18116,9 @@ }, { "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" } ] }, @@ -20209,6 +20507,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -20302,7 +20601,23 @@ "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -20695,7 +21010,7 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "expressionType": { @@ -20766,6 +21081,100 @@ } ], "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -20811,6 +21220,12 @@ }, { "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" } ] } @@ -20821,7 +21236,7 @@ "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" @@ -20830,12 +21245,58 @@ "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", "unit": "Pixel", "label": "Height of a copy", - "description": "Copies keep the proportions of the model.", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -20973,7 +21434,7 @@ "type": "Number", "unit": "Pixel", "label": "Trunk diameter", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, @@ -21028,7 +21489,13 @@ "propertyName": "Model" }, { - "propertyName": "Size" + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" }, { "propertyName": "MinScale" @@ -21041,6 +21508,9 @@ }, { "propertyName": "RandomTint" + }, + { + "propertyName": "Size" } ] }, diff --git a/examples/starting-3d-driving/starting-3d-driving.json b/examples/starting-3d-driving/starting-3d-driving.json index ae2301a7b..381c64f2e 100644 --- a/examples/starting-3d-driving/starting-3d-driving.json +++ b/examples/starting-3d-driving/starting-3d-driving.json @@ -12651,7 +12651,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.2.0", + "version": "0.3.1", "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.", "", @@ -12687,6 +12687,12 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], @@ -16091,7 +16097,7 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], @@ -16334,18 +16340,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -16356,19 +16383,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -16397,17 +16427,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -16434,8 +16473,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -16529,11 +16571,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -16546,7 +16593,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -16574,14 +16622,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -16629,9 +16680,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -16650,6 +16706,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -16762,9 +16819,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -16819,12 +16876,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -16998,8 +17055,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -17296,11 +17356,246 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -17314,6 +17609,9 @@ }, { "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" } ] }, @@ -19702,6 +20000,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -19795,7 +20094,23 @@ "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -20188,7 +20503,7 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "expressionType": { @@ -20259,6 +20574,100 @@ } ], "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -20304,6 +20713,12 @@ }, { "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" } ] } @@ -20314,7 +20729,7 @@ "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" @@ -20323,12 +20738,58 @@ "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", "unit": "Pixel", "label": "Height of a copy", - "description": "Copies keep the proportions of the model.", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -20466,7 +20927,7 @@ "type": "Number", "unit": "Pixel", "label": "Trunk diameter", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, @@ -20521,7 +20982,13 @@ "propertyName": "Model" }, { - "propertyName": "Size" + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" }, { "propertyName": "MinScale" @@ -20534,6 +21001,9 @@ }, { "propertyName": "RandomTint" + }, + { + "propertyName": "Size" } ] }, diff --git a/examples/starting-3d-grand-theft-auto/starting-3d-grand-theft-auto.json b/examples/starting-3d-grand-theft-auto/starting-3d-grand-theft-auto.json index 27b8dd02c..88bd43ce3 100644 --- a/examples/starting-3d-grand-theft-auto/starting-3d-grand-theft-auto.json +++ b/examples/starting-3d-grand-theft-auto/starting-3d-grand-theft-auto.json @@ -28604,7 +28604,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.2.0", + "version": "0.3.1", "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.", "", @@ -28640,6 +28640,12 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], @@ -32044,7 +32050,7 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], @@ -32287,18 +32293,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -32309,19 +32336,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -32350,17 +32380,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -32387,8 +32426,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -32482,11 +32524,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -32499,7 +32546,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -32527,14 +32575,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -32582,9 +32633,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -32603,6 +32659,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -32715,9 +32772,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -32772,12 +32829,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -32951,8 +33008,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -33249,11 +33309,246 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -33267,6 +33562,9 @@ }, { "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" } ] }, @@ -35655,6 +35953,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -35748,7 +36047,23 @@ "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -36141,7 +36456,7 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "expressionType": { @@ -36212,6 +36527,100 @@ } ], "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -36257,6 +36666,12 @@ }, { "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" } ] } @@ -36267,7 +36682,7 @@ "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" @@ -36276,12 +36691,58 @@ "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", "unit": "Pixel", "label": "Height of a copy", - "description": "Copies keep the proportions of the model.", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -36419,7 +36880,7 @@ "type": "Number", "unit": "Pixel", "label": "Trunk diameter", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, @@ -36474,7 +36935,13 @@ "propertyName": "Model" }, { - "propertyName": "Size" + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" }, { "propertyName": "MinScale" @@ -36487,6 +36954,9 @@ }, { "propertyName": "RandomTint" + }, + { + "propertyName": "Size" } ] }, diff --git a/examples/starting-3d-tank/starting-3d-tank.json b/examples/starting-3d-tank/starting-3d-tank.json index 4f2766be8..5586ac8dc 100644 --- a/examples/starting-3d-tank/starting-3d-tank.json +++ b/examples/starting-3d-tank/starting-3d-tank.json @@ -36633,7 +36633,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.2.0", + "version": "0.3.1", "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.", "", @@ -36669,6 +36669,12 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], @@ -40073,7 +40079,7 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], @@ -40316,18 +40322,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -40338,19 +40365,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -40379,17 +40409,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -40416,8 +40455,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -40511,11 +40553,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -40528,7 +40575,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -40556,14 +40604,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -40611,9 +40662,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -40632,6 +40688,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -40744,9 +40801,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -40801,12 +40858,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -40980,8 +41037,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -41278,11 +41338,246 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -41296,6 +41591,9 @@ }, { "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" } ] }, @@ -43684,6 +43982,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -43777,7 +44076,23 @@ "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -44170,7 +44485,7 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "expressionType": { @@ -44241,6 +44556,100 @@ } ], "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -44286,6 +44695,12 @@ }, { "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" } ] } @@ -44296,7 +44711,7 @@ "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" @@ -44305,12 +44720,58 @@ "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", "unit": "Pixel", "label": "Height of a copy", - "description": "Copies keep the proportions of the model.", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -44448,7 +44909,7 @@ "type": "Number", "unit": "Pixel", "label": "Trunk diameter", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, @@ -44503,7 +44964,13 @@ "propertyName": "Model" }, { - "propertyName": "Size" + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" }, { "propertyName": "MinScale" @@ -44516,6 +44983,9 @@ }, { "propertyName": "RandomTint" + }, + { + "propertyName": "Size" } ] }, diff --git a/examples/starting-expanded-3D-platformer/starting-expanded-3D-platformer.json b/examples/starting-expanded-3D-platformer/starting-expanded-3D-platformer.json index 23f2ced35..b46eae6ec 100644 --- a/examples/starting-expanded-3D-platformer/starting-expanded-3D-platformer.json +++ b/examples/starting-expanded-3D-platformer/starting-expanded-3D-platformer.json @@ -29667,7 +29667,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.3.1", "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.", "", @@ -29703,14 +29703,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -30765,8 +30773,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;", @@ -30775,11 +30792,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;", @@ -30788,15 +30805,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. */", @@ -30808,15 +30823,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", @@ -30834,15 +30850,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;", @@ -30853,48 +30865,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 +", @@ -30926,26 +30951,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;", @@ -30966,7 +30995,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", @@ -30974,12 +31026,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;", @@ -30987,13 +31045,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.", @@ -31052,11 +31168,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 },", @@ -31065,6 +31184,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.", @@ -31084,8 +31204,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());", @@ -31122,11 +31246,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 */", @@ -31358,16 +31499,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());", @@ -32285,40 +32448,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;", "};", "", @@ -32624,14 +32768,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -32969,18 +33113,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -33212,18 +33356,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -33234,19 +33399,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -33275,17 +33443,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -33312,8 +33489,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -33407,11 +33587,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -33424,7 +33609,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -33452,14 +33638,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -33507,9 +33696,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -33528,6 +33722,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -33640,9 +33835,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -33697,12 +33892,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -33876,8 +34071,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -34174,125 +34372,375 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] } ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, - "keepRatio": true, - "layer": "", + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, + "keepRatio": true, + "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -34342,8 +34790,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -34370,8 +34818,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -34398,8 +34846,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -34426,15 +34874,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -34458,8 +34908,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -34473,9 +34923,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -34483,13 +34933,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -34515,8 +34965,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -34540,9 +34990,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -34550,13 +35000,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -34580,8 +35030,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -34595,9 +35045,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -34605,13 +35055,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -34635,8 +35085,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -34650,9 +35100,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -34660,13 +35110,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -34690,8 +35140,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -34705,9 +35155,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -34715,13 +35165,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -34747,8 +35197,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -34772,9 +35222,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -34782,13 +35232,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -34812,8 +35262,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -34827,9 +35277,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -34837,13 +35287,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -34867,8 +35317,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -34882,9 +35332,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -34892,13 +35342,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -34916,12 +35366,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -34934,16 +35387,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -34961,12 +35411,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -34979,16 +35432,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -35011,8 +35461,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -35030,13 +35480,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -35059,8 +35509,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -35078,13 +35528,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -35110,8 +35560,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -35144,13 +35594,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -35172,8 +35622,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -35186,13 +35636,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -35211,12 +35661,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -35230,21 +35683,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -35266,29 +35716,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -35309,23 +35759,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -35345,8 +35795,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -35354,13 +35804,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -35378,12 +35828,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -35396,16 +35849,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -35423,12 +35873,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -35441,16 +35894,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -35468,12 +35918,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -35486,14 +35939,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -35538,6 +36091,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -35595,6 +36149,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -35606,6 +36161,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -35624,6 +36180,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -35635,6 +36192,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -35653,6 +36211,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -35664,6 +36223,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -35682,6 +36242,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -35693,6 +36254,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -35706,8 +36268,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -35787,8 +36349,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -35806,6 +36368,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", @@ -35814,7 +36407,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -35826,6 +36535,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -35913,16 +36623,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -35987,8 +36696,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -36015,8 +36724,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -36051,8 +36760,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -36079,13 +36788,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -36098,8 +36824,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -36107,8 +36833,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -36167,8 +36893,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -36199,13 +36925,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -36214,10 +37002,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -36227,6 +37016,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -36305,23 +37095,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -36379,8 +37184,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -36407,8 +37212,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -36439,8 +37244,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -36467,15 +37272,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -36498,8 +37305,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -36517,13 +37324,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -36546,8 +37353,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -36565,13 +37372,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -36587,25 +37394,117 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, "parameters": [ { - "description": "Object", - "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -36616,19 +37515,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -36641,18 +37543,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -36662,19 +37561,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -36687,20 +37589,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -36708,19 +37608,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -36733,20 +37636,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -36754,19 +37655,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -36779,33 +37683,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -36906,8 +37906,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -36915,8 +37915,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -36924,8 +37924,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -36941,17 +37941,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -36987,7 +37987,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] }, diff --git a/examples/starting-expanded-3d-car-racing/starting-expanded-3d-car-racing.json b/examples/starting-expanded-3d-car-racing/starting-expanded-3d-car-racing.json index 51b454b14..38f1bf54c 100644 --- a/examples/starting-expanded-3d-car-racing/starting-expanded-3d-car-racing.json +++ b/examples/starting-expanded-3d-car-racing/starting-expanded-3d-car-racing.json @@ -15055,7 +15055,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.3.1", "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.", "", @@ -15091,14 +15091,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -16153,8 +16161,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;", @@ -16163,11 +16180,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;", @@ -16176,15 +16193,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. */", @@ -16196,15 +16211,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", @@ -16222,15 +16238,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;", @@ -16241,48 +16253,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 +", @@ -16314,26 +16339,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;", @@ -16354,7 +16383,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", @@ -16362,12 +16414,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;", @@ -16375,13 +16433,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.", @@ -16440,11 +16556,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 },", @@ -16453,6 +16572,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.", @@ -16472,8 +16592,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());", @@ -16510,11 +16634,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 */", @@ -16746,16 +16887,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());", @@ -17673,40 +17836,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;", "};", "", @@ -18012,14 +18156,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -18357,18 +18501,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -18600,18 +18744,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -18622,19 +18787,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -18663,17 +18831,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -18700,8 +18877,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -18795,11 +18975,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -18812,7 +18997,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -18840,14 +19026,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -18895,9 +19084,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -18916,6 +19110,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -19028,9 +19223,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -19085,12 +19280,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -19264,8 +19459,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -19562,165 +19760,415 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 - } - ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, - "keepRatio": true, - "layer": "", - "name": "AreaPlaceholder", - "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", - "x": 0, - "y": 0, - "z": 0, - "zOrder": 1, - "numberProperties": [], - "stringProperties": [], - "initialVariables": [] - } - ], - "editionSettings": {}, - "eventsFunctions": [ - { - "fullName": "", - "functionType": "Action", - "name": "onCreated", - "sentence": "", - "events": [ + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, { - "type": "BuiltinCommonInstructions::Standard", - "conditions": [], - "actions": [ - { - "type": { - "value": "Terrain3D::DefineHelperClasses" - }, - "parameters": [ - "", - "" - ] - } + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" ] }, { - "type": "BuiltinCommonInstructions::JsCode", - "inlineCode": [ - "/** @type {gdjs.CustomRuntimeObject3D} */", - "const object = objects[0];", - "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", - " child.deleteFromScene();", - "}", - "if (object.__terrain3D) object.__terrain3D.dispose();", - "object.__terrain3D = new gdjs.__terrain3DExtension.Terrain(object);" - ], + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, + "keepRatio": true, + "layer": "", + "name": "AreaPlaceholder", + "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, + "x": 0, + "y": 0, + "zOrder": 1, + "numberProperties": [], + "stringProperties": [], + "initialVariables": [] + } + ], + "editionSettings": {}, + "eventsFunctions": [ + { + "fullName": "", + "functionType": "Action", + "name": "onCreated", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Terrain3D::DefineHelperClasses" + }, + "parameters": [ + "", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", + " child.deleteFromScene();", + "}", + "if (object.__terrain3D) object.__terrain3D.dispose();", + "object.__terrain3D = new gdjs.__terrain3DExtension.Terrain(object);" + ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false @@ -19730,8 +20178,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -19758,8 +20206,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -19786,8 +20234,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -19814,15 +20262,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -19846,8 +20296,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -19861,9 +20311,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -19871,13 +20321,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -19903,8 +20353,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -19928,9 +20378,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -19938,13 +20388,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -19968,8 +20418,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -19983,9 +20433,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -19993,13 +20443,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -20023,8 +20473,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20038,9 +20488,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -20048,13 +20498,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -20078,8 +20528,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20093,9 +20543,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -20103,13 +20553,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -20135,8 +20585,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -20160,9 +20610,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -20170,13 +20620,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -20200,8 +20650,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20215,9 +20665,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -20225,13 +20675,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -20255,8 +20705,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20270,9 +20720,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -20280,13 +20730,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -20304,12 +20754,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20322,16 +20775,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -20349,12 +20799,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20367,16 +20820,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -20399,8 +20849,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20418,13 +20868,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -20447,8 +20897,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20466,13 +20916,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -20498,8 +20948,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20532,13 +20982,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -20560,8 +21010,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20574,13 +21024,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -20599,12 +21049,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20618,21 +21071,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -20654,29 +21104,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -20697,23 +21147,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -20733,8 +21183,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -20742,13 +21192,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -20766,12 +21216,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20784,16 +21237,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -20811,12 +21261,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20829,16 +21282,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -20856,12 +21306,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20874,14 +21327,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -20926,6 +21479,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -20983,6 +21537,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -20994,6 +21549,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -21012,6 +21568,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -21023,6 +21580,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -21041,6 +21599,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -21052,6 +21611,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -21070,6 +21630,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -21081,6 +21642,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -21094,8 +21656,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -21175,8 +21737,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -21194,6 +21756,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", @@ -21202,7 +21795,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -21214,6 +21923,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -21301,16 +22011,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -21375,8 +22084,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -21403,8 +22112,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -21439,8 +22148,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -21467,13 +22176,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -21486,8 +22212,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -21495,8 +22221,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -21555,8 +22281,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -21587,13 +22313,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -21602,10 +22390,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -21615,6 +22404,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -21693,23 +22483,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -21767,8 +22572,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -21795,8 +22600,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -21827,8 +22632,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -21855,15 +22660,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -21886,8 +22693,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -21905,13 +22712,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -21934,8 +22741,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -21953,13 +22760,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -21968,32 +22775,124 @@ "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", "const object = objects[0];", - "eventsFunctionContext.returnValue = object.__terrainScatter.getCopyCount();" + "eventsFunctionContext.returnValue = object.__terrainScatter.getCopyCount();" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -22004,19 +22903,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -22029,18 +22931,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -22050,19 +22949,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -22075,20 +22977,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -22096,19 +22996,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -22121,20 +23024,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -22142,19 +23043,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -22167,33 +23071,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -22294,8 +23294,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -22303,8 +23303,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -22312,8 +23312,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -22329,17 +23329,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -22375,7 +23375,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] } diff --git a/examples/starting-expanded-3d-driving/starting-expanded-3d-driving.json b/examples/starting-expanded-3d-driving/starting-expanded-3d-driving.json index 4a994910c..a7165c2dd 100644 --- a/examples/starting-expanded-3d-driving/starting-expanded-3d-driving.json +++ b/examples/starting-expanded-3d-driving/starting-expanded-3d-driving.json @@ -14355,7 +14355,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.3.1", "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.", "", @@ -14391,14 +14391,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -15453,8 +15461,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;", @@ -15463,11 +15480,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;", @@ -15476,15 +15493,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. */", @@ -15496,15 +15511,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", @@ -15522,15 +15538,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;", @@ -15541,48 +15553,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 +", @@ -15614,26 +15639,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;", @@ -15654,7 +15683,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", @@ -15662,12 +15714,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;", @@ -15675,13 +15733,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.", @@ -15740,11 +15856,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 },", @@ -15753,6 +15872,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.", @@ -15772,8 +15892,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());", @@ -15810,11 +15934,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 */", @@ -16046,16 +16187,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());", @@ -16973,40 +17136,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;", "};", "", @@ -17312,14 +17456,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -17657,18 +17801,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -17900,18 +18044,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -17922,19 +18087,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -17963,17 +18131,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -18000,8 +18177,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -18095,11 +18275,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -18112,7 +18297,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -18140,14 +18326,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -18195,9 +18384,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -18216,6 +18410,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -18328,9 +18523,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -18385,12 +18580,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -18564,8 +18759,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -18862,165 +19060,415 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 - } - ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, - "keepRatio": true, - "layer": "", - "name": "AreaPlaceholder", - "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", - "x": 0, - "y": 0, - "z": 0, - "zOrder": 1, - "numberProperties": [], - "stringProperties": [], - "initialVariables": [] - } - ], - "editionSettings": {}, - "eventsFunctions": [ - { - "fullName": "", - "functionType": "Action", - "name": "onCreated", - "sentence": "", - "events": [ + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, { - "type": "BuiltinCommonInstructions::Standard", - "conditions": [], - "actions": [ - { - "type": { - "value": "Terrain3D::DefineHelperClasses" - }, - "parameters": [ - "", - "" - ] - } + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" ] }, { - "type": "BuiltinCommonInstructions::JsCode", - "inlineCode": [ - "/** @type {gdjs.CustomRuntimeObject3D} */", - "const object = objects[0];", - "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", - " child.deleteFromScene();", - "}", - "if (object.__terrain3D) object.__terrain3D.dispose();", - "object.__terrain3D = new gdjs.__terrain3DExtension.Terrain(object);" - ], + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, + "keepRatio": true, + "layer": "", + "name": "AreaPlaceholder", + "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, + "x": 0, + "y": 0, + "zOrder": 1, + "numberProperties": [], + "stringProperties": [], + "initialVariables": [] + } + ], + "editionSettings": {}, + "eventsFunctions": [ + { + "fullName": "", + "functionType": "Action", + "name": "onCreated", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Terrain3D::DefineHelperClasses" + }, + "parameters": [ + "", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", + " child.deleteFromScene();", + "}", + "if (object.__terrain3D) object.__terrain3D.dispose();", + "object.__terrain3D = new gdjs.__terrain3DExtension.Terrain(object);" + ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false @@ -19030,8 +19478,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -19058,8 +19506,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -19086,8 +19534,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -19114,15 +19562,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -19146,8 +19596,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -19161,9 +19611,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -19171,13 +19621,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -19203,8 +19653,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -19228,9 +19678,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -19238,13 +19688,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -19268,8 +19718,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -19283,9 +19733,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -19293,13 +19743,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -19323,8 +19773,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -19338,9 +19788,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -19348,13 +19798,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -19378,8 +19828,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -19393,9 +19843,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -19403,13 +19853,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -19435,8 +19885,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -19460,9 +19910,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -19470,13 +19920,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -19500,8 +19950,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -19515,9 +19965,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -19525,13 +19975,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -19555,8 +20005,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -19570,9 +20020,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -19580,13 +20030,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -19604,12 +20054,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -19622,16 +20075,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -19649,12 +20099,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -19667,16 +20120,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -19699,8 +20149,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -19718,13 +20168,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -19747,8 +20197,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -19766,13 +20216,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -19798,8 +20248,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -19832,13 +20282,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -19860,8 +20310,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -19874,13 +20324,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -19899,12 +20349,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -19918,21 +20371,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -19954,29 +20404,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -19997,23 +20447,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -20033,8 +20483,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -20042,13 +20492,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -20066,12 +20516,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20084,16 +20537,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -20111,12 +20561,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20129,16 +20582,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -20156,12 +20606,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20174,14 +20627,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -20226,6 +20779,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -20283,6 +20837,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -20294,6 +20849,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -20312,6 +20868,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -20323,6 +20880,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -20341,6 +20899,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -20352,6 +20911,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -20370,6 +20930,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -20381,6 +20942,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -20394,8 +20956,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -20475,8 +21037,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -20494,6 +21056,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", @@ -20502,7 +21095,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -20514,6 +21223,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -20601,16 +21311,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -20675,8 +21384,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -20703,8 +21412,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -20739,8 +21448,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -20767,13 +21476,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -20786,8 +21512,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -20795,8 +21521,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -20855,8 +21581,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -20887,13 +21613,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -20902,10 +21690,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -20915,6 +21704,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -20993,23 +21783,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -21067,8 +21872,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -21095,8 +21900,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -21127,8 +21932,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -21155,15 +21960,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -21186,8 +21993,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -21205,13 +22012,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -21234,8 +22041,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -21253,13 +22060,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -21268,32 +22075,124 @@ "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", "const object = objects[0];", - "eventsFunctionContext.returnValue = object.__terrainScatter.getCopyCount();" + "eventsFunctionContext.returnValue = object.__terrainScatter.getCopyCount();" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -21304,19 +22203,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -21329,18 +22231,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -21350,19 +22249,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -21375,20 +22277,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -21396,19 +22296,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -21421,20 +22324,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -21442,19 +22343,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -21467,33 +22371,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -21594,8 +22594,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -21603,8 +22603,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -21612,8 +22612,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -21629,17 +22629,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -21675,7 +22675,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] } diff --git a/examples/starting-expanded-3d-flight-sim/starting-expanded-3d-flight-sim.json b/examples/starting-expanded-3d-flight-sim/starting-expanded-3d-flight-sim.json index 838e7e66c..52022e31d 100644 --- a/examples/starting-expanded-3d-flight-sim/starting-expanded-3d-flight-sim.json +++ b/examples/starting-expanded-3d-flight-sim/starting-expanded-3d-flight-sim.json @@ -12747,7 +12747,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.3.1", "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.", "", @@ -12783,14 +12783,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -13845,8 +13853,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;", @@ -13855,11 +13872,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;", @@ -13868,15 +13885,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. */", @@ -13888,15 +13903,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", @@ -13914,15 +13930,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;", @@ -13933,48 +13945,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 +", @@ -14006,26 +14031,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;", @@ -14046,7 +14075,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", @@ -14054,12 +14106,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;", @@ -14067,13 +14125,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.", @@ -14132,11 +14248,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 },", @@ -14145,6 +14264,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.", @@ -14164,8 +14284,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());", @@ -14202,11 +14326,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 */", @@ -14438,16 +14579,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());", @@ -15365,40 +15528,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;", "};", "", @@ -15704,14 +15848,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -16049,18 +16193,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -16292,18 +16436,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -16314,19 +16479,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -16355,17 +16523,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -16392,8 +16569,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -16487,11 +16667,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -16504,7 +16689,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -16532,14 +16718,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -16587,9 +16776,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -16608,6 +16802,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -16720,9 +16915,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -16777,12 +16972,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -16956,8 +17151,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -17254,152 +17452,402 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ - { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ - { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 - } - ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, - "keepRatio": true, - "layer": "", - "name": "AreaPlaceholder", - "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", - "x": 0, - "y": 0, - "z": 0, - "zOrder": 1, - "numberProperties": [], - "stringProperties": [], - "initialVariables": [] - } - ], - "editionSettings": {}, - "eventsFunctions": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "fullName": "", - "functionType": "Action", - "name": "onCreated", - "sentence": "", - "events": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "type": "BuiltinCommonInstructions::Standard", - "conditions": [], - "actions": [ - { - "type": { - "value": "Terrain3D::DefineHelperClasses" - }, - "parameters": [ - "", - "" - ] - } + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, + "keepRatio": true, + "layer": "", + "name": "AreaPlaceholder", + "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, + "x": 0, + "y": 0, + "zOrder": 1, + "numberProperties": [], + "stringProperties": [], + "initialVariables": [] + } + ], + "editionSettings": {}, + "eventsFunctions": [ + { + "fullName": "", + "functionType": "Action", + "name": "onCreated", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Terrain3D::DefineHelperClasses" + }, + "parameters": [ + "", + "" + ] + } ] }, { @@ -17422,8 +17870,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -17450,8 +17898,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -17478,8 +17926,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -17506,15 +17954,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -17538,8 +17988,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -17553,9 +18003,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -17563,13 +18013,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -17595,8 +18045,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -17620,9 +18070,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -17630,13 +18080,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -17660,8 +18110,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -17675,9 +18125,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -17685,13 +18135,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -17715,8 +18165,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -17730,9 +18180,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -17740,13 +18190,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -17770,8 +18220,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -17785,9 +18235,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -17795,13 +18245,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -17827,8 +18277,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -17852,9 +18302,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -17862,13 +18312,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -17892,8 +18342,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -17907,9 +18357,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -17917,13 +18367,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -17947,8 +18397,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -17962,9 +18412,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -17972,13 +18422,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -17996,12 +18446,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -18014,16 +18467,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -18041,12 +18491,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -18058,17 +18511,14 @@ "name": "Y", "type": "expression" } - ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + ], + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -18091,8 +18541,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -18110,13 +18560,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -18139,8 +18589,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -18158,13 +18608,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -18190,8 +18640,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -18224,13 +18674,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -18252,8 +18702,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -18266,13 +18716,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -18291,12 +18741,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -18310,21 +18763,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -18346,29 +18796,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -18389,23 +18839,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -18425,8 +18875,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -18434,13 +18884,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -18458,12 +18908,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -18476,16 +18929,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -18503,12 +18953,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -18521,16 +18974,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -18548,12 +18998,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -18566,14 +19019,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -18618,6 +19171,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -18675,6 +19229,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -18686,6 +19241,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -18704,6 +19260,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -18715,6 +19272,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -18733,6 +19291,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -18744,6 +19303,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -18762,6 +19322,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -18773,6 +19334,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -18786,8 +19348,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -18867,8 +19429,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -18886,6 +19448,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", @@ -18894,7 +19487,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -18906,6 +19615,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -18993,16 +19703,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -19067,8 +19776,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -19095,8 +19804,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -19131,8 +19840,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -19159,13 +19868,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -19178,8 +19904,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -19187,8 +19913,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -19247,8 +19973,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -19279,13 +20005,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -19294,10 +20082,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -19307,6 +20096,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -19385,23 +20175,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -19459,8 +20264,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -19487,8 +20292,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -19519,8 +20324,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -19547,15 +20352,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -19578,8 +20385,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -19597,13 +20404,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -19626,8 +20433,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -19645,13 +20452,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -19667,25 +20474,117 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, "parameters": [ { - "description": "Object", - "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -19696,19 +20595,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -19721,18 +20623,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -19742,19 +20641,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -19767,20 +20669,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -19788,19 +20688,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -19813,20 +20716,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -19834,19 +20735,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -19859,33 +20763,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -19986,8 +20986,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -19995,8 +20995,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -20004,8 +21004,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -20021,17 +21021,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -20067,7 +21067,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] }, diff --git a/examples/starting-expanded-3d-grand-theft-auto/starting-expanded-3d-grand-theft-auto.json b/examples/starting-expanded-3d-grand-theft-auto/starting-expanded-3d-grand-theft-auto.json index f20c1c91b..a6e4b5da6 100644 --- a/examples/starting-expanded-3d-grand-theft-auto/starting-expanded-3d-grand-theft-auto.json +++ b/examples/starting-expanded-3d-grand-theft-auto/starting-expanded-3d-grand-theft-auto.json @@ -31884,7 +31884,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.3.1", "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.", "", @@ -31920,14 +31920,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -32982,8 +32990,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;", @@ -32992,11 +33009,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;", @@ -33005,15 +33022,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. */", @@ -33025,15 +33040,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", @@ -33051,15 +33067,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;", @@ -33070,48 +33082,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 +", @@ -33143,26 +33168,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;", @@ -33183,7 +33212,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", @@ -33191,12 +33243,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;", @@ -33204,13 +33262,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.", @@ -33269,11 +33385,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 },", @@ -33282,6 +33401,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.", @@ -33301,8 +33421,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());", @@ -33339,11 +33463,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 */", @@ -33575,16 +33716,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());", @@ -34502,40 +34665,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;", "};", "", @@ -34841,14 +34985,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -35186,18 +35330,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -35429,18 +35573,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -35451,19 +35616,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -35492,17 +35660,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -35529,8 +35706,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -35624,11 +35804,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -35641,7 +35826,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -35669,14 +35855,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -35724,9 +35913,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -35745,6 +35939,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -35857,9 +36052,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -35914,12 +36109,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -36093,8 +36288,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -36391,164 +36589,414 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 - } - ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, - "keepRatio": true, - "layer": "", - "name": "AreaPlaceholder", - "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", - "x": 0, - "y": 0, - "z": 0, - "zOrder": 1, - "numberProperties": [], - "stringProperties": [], - "initialVariables": [] - } - ], - "editionSettings": {}, - "eventsFunctions": [ - { - "fullName": "", - "functionType": "Action", - "name": "onCreated", - "sentence": "", - "events": [ + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, { - "type": "BuiltinCommonInstructions::Standard", - "conditions": [], - "actions": [ - { - "type": { - "value": "Terrain3D::DefineHelperClasses" - }, - "parameters": [ - "", - "" - ] - } + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" ] }, { - "type": "BuiltinCommonInstructions::JsCode", - "inlineCode": [ - "/** @type {gdjs.CustomRuntimeObject3D} */", - "const object = objects[0];", - "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", - " child.deleteFromScene();", - "}", - "if (object.__terrain3D) object.__terrain3D.dispose();", - "object.__terrain3D = new gdjs.__terrain3DExtension.Terrain(object);" + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, + "keepRatio": true, + "layer": "", + "name": "AreaPlaceholder", + "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, + "x": 0, + "y": 0, + "zOrder": 1, + "numberProperties": [], + "stringProperties": [], + "initialVariables": [] + } + ], + "editionSettings": {}, + "eventsFunctions": [ + { + "fullName": "", + "functionType": "Action", + "name": "onCreated", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Terrain3D::DefineHelperClasses" + }, + "parameters": [ + "", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", + " child.deleteFromScene();", + "}", + "if (object.__terrain3D) object.__terrain3D.dispose();", + "object.__terrain3D = new gdjs.__terrain3DExtension.Terrain(object);" ], "parameterObjects": "Object", "useStrict": true, @@ -36559,8 +37007,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -36587,8 +37035,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -36615,8 +37063,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -36643,15 +37091,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -36675,8 +37125,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -36690,9 +37140,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -36700,13 +37150,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -36732,8 +37182,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -36757,9 +37207,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -36767,13 +37217,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -36797,8 +37247,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -36812,9 +37262,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -36822,13 +37272,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -36852,8 +37302,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -36867,9 +37317,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -36877,13 +37327,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -36907,8 +37357,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -36922,9 +37372,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -36932,13 +37382,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -36964,8 +37414,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -36989,9 +37439,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -36999,13 +37449,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -37029,8 +37479,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -37044,9 +37494,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -37054,13 +37504,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -37084,8 +37534,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -37099,9 +37549,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -37109,13 +37559,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -37133,12 +37583,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -37151,16 +37604,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -37178,12 +37628,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -37196,16 +37649,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -37228,8 +37678,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -37247,13 +37697,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -37276,8 +37726,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -37295,13 +37745,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -37327,8 +37777,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -37361,13 +37811,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -37389,8 +37839,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -37403,13 +37853,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -37428,12 +37878,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -37447,21 +37900,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -37483,29 +37933,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -37526,23 +37976,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -37562,8 +38012,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -37571,13 +38021,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -37595,12 +38045,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -37613,16 +38066,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -37640,12 +38090,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -37658,16 +38111,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -37685,12 +38135,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -37703,14 +38156,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -37755,6 +38308,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -37812,6 +38366,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -37823,6 +38378,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -37841,6 +38397,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -37852,6 +38409,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -37870,6 +38428,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -37881,6 +38440,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -37899,6 +38459,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -37910,6 +38471,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -37923,8 +38485,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -38004,8 +38566,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -38023,6 +38585,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", @@ -38031,7 +38624,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -38043,6 +38752,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -38130,16 +38840,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -38204,8 +38913,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -38232,8 +38941,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -38268,8 +38977,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -38296,13 +39005,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -38315,8 +39041,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -38324,8 +39050,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -38384,8 +39110,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -38416,13 +39142,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -38431,10 +39219,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -38444,6 +39233,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -38522,23 +39312,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -38596,8 +39401,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -38624,8 +39429,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -38656,8 +39461,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -38684,15 +39489,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -38715,8 +39522,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -38734,13 +39541,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -38763,8 +39570,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -38782,13 +39589,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -38804,25 +39611,117 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -38833,19 +39732,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -38858,18 +39760,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -38879,19 +39778,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -38904,20 +39806,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -38925,19 +39825,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -38950,20 +39853,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -38971,19 +39872,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -38996,33 +39900,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -39123,8 +40123,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -39132,8 +40132,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -39141,8 +40141,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -39158,17 +40158,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -39204,7 +40204,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] } diff --git a/examples/starting-expanded-3d-rts-unit-selection/starting-expanded-3d-rts-unit-selection.json b/examples/starting-expanded-3d-rts-unit-selection/starting-expanded-3d-rts-unit-selection.json index 77f00a65d..2e10c665c 100644 --- a/examples/starting-expanded-3d-rts-unit-selection/starting-expanded-3d-rts-unit-selection.json +++ b/examples/starting-expanded-3d-rts-unit-selection/starting-expanded-3d-rts-unit-selection.json @@ -1941,6 +1941,7 @@ "behaviors": [], "content": { "Model": "assets\\grass_multiple_blades.glb", + "ModelAnchor": "BaseCenter", "Size": 24, "Density": 0.02, "Seed": 7, @@ -15374,7 +15375,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.3.1", "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.", "", @@ -15410,14 +15411,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -16472,8 +16481,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;", @@ -16482,11 +16500,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;", @@ -16495,15 +16513,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. */", @@ -16515,15 +16531,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", @@ -16541,15 +16558,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;", @@ -16560,48 +16573,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 +", @@ -16633,26 +16659,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;", @@ -16673,7 +16703,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", @@ -16681,12 +16734,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;", @@ -16694,13 +16753,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.", @@ -16759,11 +16876,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 },", @@ -16772,6 +16892,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.", @@ -16791,8 +16912,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());", @@ -16829,11 +16954,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 */", @@ -17065,16 +17207,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());", @@ -17992,40 +18156,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;", "};", "", @@ -18331,14 +18476,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -18676,18 +18821,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -18919,18 +19064,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -18941,19 +19107,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -18982,17 +19151,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -19019,8 +19197,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -19114,11 +19295,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -19131,7 +19317,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -19159,14 +19346,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -19214,9 +19404,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -19235,6 +19430,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -19347,9 +19543,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -19404,12 +19600,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -19583,8 +19779,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -19881,125 +20080,375 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] } ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, - "keepRatio": true, - "layer": "", - "name": "AreaPlaceholder", - "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", - "x": 0, - "y": 0, - "z": 0, + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, + "keepRatio": true, + "layer": "", + "name": "AreaPlaceholder", + "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, + "x": 0, + "y": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -20049,8 +20498,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -20077,8 +20526,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -20105,8 +20554,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -20133,15 +20582,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -20165,8 +20616,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20180,9 +20631,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -20190,13 +20641,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -20222,8 +20673,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -20247,9 +20698,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -20257,13 +20708,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -20287,8 +20738,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20302,9 +20753,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -20312,13 +20763,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -20342,8 +20793,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20357,9 +20808,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -20367,13 +20818,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -20397,8 +20848,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20412,9 +20863,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -20422,13 +20873,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -20454,8 +20905,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -20479,9 +20930,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -20489,13 +20940,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -20519,8 +20970,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20534,9 +20985,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -20544,13 +20995,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -20574,8 +21025,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20589,9 +21040,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -20599,13 +21050,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -20623,12 +21074,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20641,16 +21095,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -20668,12 +21119,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20686,16 +21140,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -20718,8 +21169,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20737,13 +21188,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -20766,8 +21217,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20785,13 +21236,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -20817,8 +21268,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -20851,13 +21302,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -20879,8 +21330,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20893,13 +21344,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -20918,12 +21369,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -20937,21 +21391,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -20973,29 +21424,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -21016,23 +21467,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -21052,8 +21503,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -21061,13 +21512,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -21085,12 +21536,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -21103,16 +21557,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -21130,12 +21581,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -21148,16 +21602,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -21175,12 +21626,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -21193,14 +21647,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -21245,6 +21799,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -21302,6 +21857,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -21313,6 +21869,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -21331,6 +21888,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -21342,6 +21900,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -21360,6 +21919,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -21371,6 +21931,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -21389,6 +21950,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -21400,6 +21962,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -21413,8 +21976,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -21494,8 +22057,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -21513,6 +22076,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", @@ -21521,7 +22115,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -21533,6 +22243,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -21620,16 +22331,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -21694,8 +22404,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -21722,8 +22432,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -21758,8 +22468,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -21786,13 +22496,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -21805,8 +22532,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -21814,8 +22541,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -21874,8 +22601,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -21906,13 +22633,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -21921,10 +22710,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -21934,6 +22724,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -22012,23 +22803,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -22086,8 +22892,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -22114,8 +22920,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -22146,8 +22952,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -22174,15 +22980,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -22205,8 +23013,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -22224,13 +23032,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -22253,8 +23061,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -22272,13 +23080,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -22294,25 +23102,117 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -22323,19 +23223,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -22348,18 +23251,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -22369,19 +23269,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -22394,20 +23297,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -22415,19 +23316,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -22440,20 +23344,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -22461,19 +23363,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -22486,33 +23391,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -22613,8 +23614,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -22622,8 +23623,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -22631,8 +23632,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -22648,17 +23649,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -22694,7 +23695,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] }, diff --git a/examples/starting-expanded-3d-tank/starting-expanded-3d-tank.json b/examples/starting-expanded-3d-tank/starting-expanded-3d-tank.json index 43bb77c61..660325880 100644 --- a/examples/starting-expanded-3d-tank/starting-expanded-3d-tank.json +++ b/examples/starting-expanded-3d-tank/starting-expanded-3d-tank.json @@ -38547,7 +38547,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.3.1", "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.", "", @@ -38583,14 +38583,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -39645,8 +39653,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;", @@ -39655,11 +39672,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;", @@ -39668,15 +39685,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. */", @@ -39688,15 +39703,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", @@ -39714,15 +39730,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;", @@ -39733,48 +39745,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 +", @@ -39806,26 +39831,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;", @@ -39846,7 +39875,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", @@ -39854,12 +39906,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;", @@ -39867,13 +39925,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.", @@ -39932,11 +40048,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 },", @@ -39945,6 +40064,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.", @@ -39964,8 +40084,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());", @@ -40002,11 +40126,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 */", @@ -40238,16 +40379,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());", @@ -41165,40 +41328,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;", "};", "", @@ -41504,14 +41648,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -41849,18 +41993,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -42092,18 +42236,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -42114,19 +42279,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -42155,17 +42323,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -42192,8 +42369,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -42287,11 +42467,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -42304,7 +42489,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -42332,14 +42518,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -42387,9 +42576,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -42408,6 +42602,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -42520,9 +42715,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -42577,12 +42772,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -42756,8 +42951,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -43054,163 +43252,413 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 - } - ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, - "keepRatio": true, - "layer": "", - "name": "AreaPlaceholder", - "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", - "x": 0, - "y": 0, - "z": 0, - "zOrder": 1, - "numberProperties": [], - "stringProperties": [], - "initialVariables": [] - } - ], - "editionSettings": {}, - "eventsFunctions": [ - { - "fullName": "", - "functionType": "Action", - "name": "onCreated", - "sentence": "", - "events": [ + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, { - "type": "BuiltinCommonInstructions::Standard", - "conditions": [], - "actions": [ - { - "type": { - "value": "Terrain3D::DefineHelperClasses" - }, - "parameters": [ - "", - "" - ] - } + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" ] }, { - "type": "BuiltinCommonInstructions::JsCode", - "inlineCode": [ - "/** @type {gdjs.CustomRuntimeObject3D} */", - "const object = objects[0];", - "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", - " child.deleteFromScene();", - "}", - "if (object.__terrain3D) object.__terrain3D.dispose();", + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, + "keepRatio": true, + "layer": "", + "name": "AreaPlaceholder", + "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, + "x": 0, + "y": 0, + "zOrder": 1, + "numberProperties": [], + "stringProperties": [], + "initialVariables": [] + } + ], + "editionSettings": {}, + "eventsFunctions": [ + { + "fullName": "", + "functionType": "Action", + "name": "onCreated", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Terrain3D::DefineHelperClasses" + }, + "parameters": [ + "", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", + " child.deleteFromScene();", + "}", + "if (object.__terrain3D) object.__terrain3D.dispose();", "object.__terrain3D = new gdjs.__terrain3DExtension.Terrain(object);" ], "parameterObjects": "Object", @@ -43222,8 +43670,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -43250,8 +43698,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -43278,8 +43726,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -43306,15 +43754,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -43338,8 +43788,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -43353,9 +43803,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -43363,13 +43813,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -43395,8 +43845,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -43420,9 +43870,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -43430,13 +43880,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -43460,8 +43910,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -43475,9 +43925,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -43485,13 +43935,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -43515,8 +43965,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -43530,9 +43980,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -43540,13 +43990,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -43570,8 +44020,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -43585,9 +44035,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -43595,13 +44045,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -43627,8 +44077,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -43652,9 +44102,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -43662,13 +44112,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -43692,8 +44142,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -43707,9 +44157,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -43717,13 +44167,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -43747,8 +44197,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -43762,9 +44212,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -43772,13 +44222,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -43796,12 +44246,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -43814,16 +44267,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -43841,12 +44291,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -43859,16 +44312,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -43891,8 +44341,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -43910,13 +44360,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -43939,8 +44389,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -43958,13 +44408,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -43990,8 +44440,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -44024,13 +44474,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -44052,8 +44502,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -44066,13 +44516,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -44091,12 +44541,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -44110,21 +44563,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -44146,29 +44596,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -44189,23 +44639,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -44225,8 +44675,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -44234,13 +44684,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -44258,12 +44708,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -44276,16 +44729,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -44303,12 +44753,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -44321,16 +44774,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -44348,12 +44798,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -44366,14 +44819,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -44418,6 +44971,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -44475,6 +45029,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -44486,6 +45041,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -44504,6 +45060,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -44515,6 +45072,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -44533,6 +45091,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -44544,6 +45103,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -44562,6 +45122,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -44573,6 +45134,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -44586,8 +45148,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -44667,8 +45229,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -44686,6 +45248,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", @@ -44694,7 +45287,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -44706,6 +45415,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -44793,16 +45503,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -44867,8 +45576,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -44895,8 +45604,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -44931,8 +45640,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -44959,13 +45668,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -44978,8 +45704,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -44987,8 +45713,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -45047,8 +45773,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -45079,13 +45805,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -45094,10 +45882,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -45107,6 +45896,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -45185,23 +45975,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -45259,8 +46064,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -45287,8 +46092,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -45319,8 +46124,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -45347,15 +46152,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -45378,8 +46185,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -45397,13 +46204,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -45426,8 +46233,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -45445,13 +46252,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -45467,25 +46274,117 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -45496,19 +46395,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -45521,18 +46423,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -45542,19 +46441,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -45567,20 +46469,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -45588,19 +46488,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -45613,20 +46516,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -45634,19 +46535,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -45659,33 +46563,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -45786,8 +46786,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -45795,8 +46795,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -45804,8 +46804,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -45821,17 +46821,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -45867,7 +46867,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] } diff --git a/examples/starting-expanded-3d-top-down-rpg/starting-expanded-3d-top-down-rpg.json b/examples/starting-expanded-3d-top-down-rpg/starting-expanded-3d-top-down-rpg.json index 27dd78458..25fd21572 100644 --- a/examples/starting-expanded-3d-top-down-rpg/starting-expanded-3d-top-down-rpg.json +++ b/examples/starting-expanded-3d-top-down-rpg/starting-expanded-3d-top-down-rpg.json @@ -2444,6 +2444,7 @@ "behaviors": [], "content": { "Model": "assets\\grass_multiple_blades.glb", + "ModelAnchor": "BaseCenter", "Size": 40, "Density": 0.03, "Seed": 4, @@ -47143,7 +47144,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.3.1", "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.", "", @@ -47179,14 +47180,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -48241,8 +48250,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;", @@ -48251,11 +48269,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;", @@ -48264,15 +48282,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. */", @@ -48284,15 +48300,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", @@ -48310,15 +48327,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;", @@ -48329,48 +48342,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 +", @@ -48402,26 +48428,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;", @@ -48442,7 +48472,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", @@ -48450,12 +48503,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;", @@ -48463,13 +48522,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.", @@ -48528,11 +48645,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 },", @@ -48541,6 +48661,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.", @@ -48560,8 +48681,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());", @@ -48598,11 +48723,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 */", @@ -48834,16 +48976,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());", @@ -49761,40 +49925,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;", "};", "", @@ -50100,14 +50245,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -50445,18 +50590,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -50688,18 +50833,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -50710,19 +50876,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -50751,17 +50920,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -50788,8 +50966,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -50883,11 +51064,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -50900,7 +51086,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -50928,14 +51115,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -50983,9 +51173,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -51004,6 +51199,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -51116,9 +51312,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -51173,12 +51369,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -51352,8 +51548,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -51650,15 +51849,265 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true + "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, "eventsBasedBehaviors": [], "eventsBasedObjects": [ { @@ -51668,10 +52117,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "Terrain", "assetStoreTag": "3d terrains", + "defaultName": "Terrain", "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", "fullName": "3D terrain", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -51759,16 +52209,15 @@ { "angle": 0, "customSize": true, - "width": 4096, - "height": 4096, "depth": 600, + "height": 4096, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -51818,8 +52267,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -51846,8 +52295,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -51874,8 +52323,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -51902,15 +52351,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -51934,8 +52385,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -51949,9 +52400,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -51959,13 +52410,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -51991,8 +52442,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -52016,9 +52467,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -52026,13 +52477,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -52056,8 +52507,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -52071,9 +52522,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -52081,13 +52532,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -52111,8 +52562,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -52126,9 +52577,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -52136,13 +52587,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -52166,8 +52617,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -52181,9 +52632,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -52191,13 +52642,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -52223,8 +52674,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -52248,9 +52699,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -52258,13 +52709,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -52288,8 +52739,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -52303,9 +52754,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -52313,13 +52764,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -52343,8 +52794,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -52358,9 +52809,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -52368,13 +52819,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -52392,12 +52843,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -52410,16 +52864,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -52437,12 +52888,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -52455,16 +52909,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -52487,8 +52938,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -52506,13 +52957,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -52535,8 +52986,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -52554,13 +53005,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -52586,8 +53037,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -52620,13 +53071,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -52648,8 +53099,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -52662,13 +53113,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -52687,12 +53138,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -52706,21 +53160,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -52742,29 +53193,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -52785,23 +53236,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -52821,8 +53272,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -52830,13 +53281,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -52854,12 +53305,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -52872,16 +53326,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -52899,12 +53350,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -52917,16 +53371,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -52944,12 +53395,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -52962,14 +53416,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -53014,6 +53568,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -53071,6 +53626,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -53082,6 +53638,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -53100,6 +53657,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -53111,6 +53669,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -53129,6 +53688,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -53140,6 +53700,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -53158,6 +53719,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -53169,6 +53731,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -53182,8 +53745,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -53263,8 +53826,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -53282,6 +53845,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", @@ -53290,7 +53884,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -53302,6 +54012,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -53389,16 +54100,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -53463,8 +54173,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -53491,8 +54201,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -53527,8 +54237,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -53555,13 +54265,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -53574,8 +54301,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -53583,8 +54310,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -53643,8 +54370,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -53675,13 +54402,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -53690,10 +54479,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -53703,6 +54493,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -53781,23 +54572,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -53855,8 +54661,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -53883,8 +54689,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -53915,8 +54721,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -53943,15 +54749,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -53974,8 +54782,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -53993,13 +54801,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -54022,8 +54830,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -54041,13 +54849,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -54063,24 +54871,24 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", "fullName": "X position of the nearest copy", "functionType": "Expression", + "group": "Copies", "name": "NearestCopyX", "sentence": "", "events": [ @@ -54099,12 +54907,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -54117,16 +54928,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", "fullName": "Y position of the nearest copy", "functionType": "Expression", + "group": "Copies", "name": "NearestCopyY", "sentence": "", "events": [ @@ -54145,12 +54953,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -54163,16 +54974,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The Z position of the base of the copy nearest to a position (on the ground).", "fullName": "Z position of the nearest copy", "functionType": "Expression", + "group": "Copies", "name": "NearestCopyZ", "sentence": "", "events": [ @@ -54188,15 +54996,18 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -54209,16 +55020,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", "fullName": "Distance to the nearest copy", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "NearestCopyDistance", "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ @@ -54237,12 +55045,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -54255,33 +55066,223 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], "expressionType": { "type": "expression" - } + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -54382,8 +55383,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -54391,8 +55392,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -54400,8 +55401,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -54417,17 +55418,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -54463,7 +55464,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] }, diff --git a/examples/starting-expanded-first-person-farming/starting-expanded-first-person-farming.json b/examples/starting-expanded-first-person-farming/starting-expanded-first-person-farming.json index c741d7c97..8d3236898 100644 --- a/examples/starting-expanded-first-person-farming/starting-expanded-first-person-farming.json +++ b/examples/starting-expanded-first-person-farming/starting-expanded-first-person-farming.json @@ -50135,7 +50135,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.3.1", "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.", "", @@ -50171,14 +50171,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -51233,8 +51241,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;", @@ -51243,11 +51260,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;", @@ -51256,15 +51273,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. */", @@ -51276,15 +51291,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", @@ -51302,15 +51318,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;", @@ -51321,48 +51333,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 +", @@ -51394,26 +51419,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;", @@ -51434,7 +51463,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", @@ -51442,12 +51494,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;", @@ -51455,13 +51513,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.", @@ -51520,11 +51636,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 },", @@ -51533,6 +51652,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.", @@ -51552,8 +51672,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());", @@ -51590,11 +51714,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 */", @@ -51826,16 +51967,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());", @@ -52753,40 +52916,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;", "};", "", @@ -53092,14 +53236,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -53437,18 +53581,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -53680,18 +53824,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -53702,19 +53867,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -53743,17 +53911,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -53780,8 +53957,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -53875,11 +54055,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -53892,7 +54077,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -53920,14 +54106,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -53975,9 +54164,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -53996,6 +54190,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -54108,9 +54303,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -54165,12 +54360,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -54344,8 +54539,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -54642,125 +54840,375 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] } ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -54810,8 +55258,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -54838,8 +55286,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -54866,8 +55314,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -54894,15 +55342,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -54926,8 +55376,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -54941,9 +55391,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -54951,13 +55401,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -54983,8 +55433,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -55008,9 +55458,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -55018,13 +55468,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -55048,8 +55498,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -55063,9 +55513,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -55073,13 +55523,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -55103,8 +55553,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -55118,9 +55568,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -55128,13 +55578,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -55158,8 +55608,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -55173,9 +55623,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -55183,13 +55633,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -55215,8 +55665,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -55240,9 +55690,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -55250,13 +55700,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -55280,8 +55730,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -55295,9 +55745,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -55305,13 +55755,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -55335,8 +55785,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -55350,9 +55800,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -55360,13 +55810,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -55384,12 +55834,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -55402,16 +55855,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -55429,12 +55879,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -55447,16 +55900,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -55479,8 +55929,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -55498,13 +55948,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -55527,8 +55977,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -55546,13 +55996,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -55578,8 +56028,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -55612,13 +56062,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -55640,8 +56090,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -55654,13 +56104,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -55679,12 +56129,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -55698,21 +56151,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -55734,29 +56184,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -55777,23 +56227,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -55813,8 +56263,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -55822,13 +56272,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -55846,12 +56296,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -55864,16 +56317,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -55891,12 +56341,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -55909,16 +56362,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -55936,12 +56386,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -55954,14 +56407,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -56006,6 +56559,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -56063,6 +56617,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -56074,6 +56629,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -56092,6 +56648,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -56103,6 +56660,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -56121,6 +56679,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -56132,6 +56691,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -56150,6 +56710,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -56161,6 +56722,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -56174,8 +56736,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -56255,8 +56817,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -56274,6 +56836,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", @@ -56282,7 +56875,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -56294,6 +57003,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -56381,16 +57091,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -56455,8 +57164,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -56483,8 +57192,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -56519,8 +57228,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -56547,13 +57256,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -56566,8 +57292,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -56575,8 +57301,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -56635,8 +57361,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -56667,13 +57393,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -56682,10 +57470,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -56695,6 +57484,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -56773,23 +57563,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -56847,8 +57652,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -56875,8 +57680,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -56907,8 +57712,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -56935,15 +57740,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -56966,8 +57773,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -56985,13 +57792,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -57014,8 +57821,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -57033,13 +57840,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -57055,25 +57862,117 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, "parameters": [ { - "description": "Object", - "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -57084,19 +57983,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -57109,18 +58011,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -57130,19 +58029,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -57155,20 +58057,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -57176,19 +58076,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -57201,20 +58104,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -57222,19 +58123,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -57247,33 +58151,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -57374,8 +58374,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -57383,8 +58383,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -57392,8 +58392,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -57409,17 +58409,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -57455,7 +58455,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] }, diff --git a/examples/starting-expanded-first-person-horror/starting-expanded-first-person-horror.json b/examples/starting-expanded-first-person-horror/starting-expanded-first-person-horror.json index 7fb72d2ea..f832e279b 100644 --- a/examples/starting-expanded-first-person-horror/starting-expanded-first-person-horror.json +++ b/examples/starting-expanded-first-person-horror/starting-expanded-first-person-horror.json @@ -30763,7 +30763,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.3.1", "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.", "", @@ -30799,14 +30799,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -31861,8 +31869,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;", @@ -31871,11 +31888,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;", @@ -31884,15 +31901,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. */", @@ -31904,15 +31919,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", @@ -31930,15 +31946,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;", @@ -31949,48 +31961,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 +", @@ -32022,26 +32047,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;", @@ -32062,7 +32091,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", @@ -32070,12 +32122,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;", @@ -32083,13 +32141,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.", @@ -32148,11 +32264,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 },", @@ -32161,6 +32280,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.", @@ -32180,8 +32300,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());", @@ -32218,11 +32342,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 */", @@ -32454,16 +32595,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());", @@ -33381,40 +33544,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;", "};", "", @@ -33720,14 +33864,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -34065,18 +34209,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -34308,18 +34452,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -34330,19 +34495,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -34371,17 +34539,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -34408,8 +34585,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -34503,11 +34683,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -34520,7 +34705,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -34548,14 +34734,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -34603,9 +34792,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -34624,6 +34818,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -34736,9 +34931,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -34793,12 +34988,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -34972,8 +35167,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -35270,164 +35468,414 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 - } - ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, - "keepRatio": true, - "layer": "", - "name": "AreaPlaceholder", - "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", - "x": 0, - "y": 0, - "z": 0, - "zOrder": 1, - "numberProperties": [], - "stringProperties": [], - "initialVariables": [] - } - ], - "editionSettings": {}, - "eventsFunctions": [ - { - "fullName": "", - "functionType": "Action", - "name": "onCreated", - "sentence": "", - "events": [ + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, { - "type": "BuiltinCommonInstructions::Standard", - "conditions": [], - "actions": [ - { - "type": { - "value": "Terrain3D::DefineHelperClasses" - }, - "parameters": [ - "", - "" - ] - } + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" ] }, { - "type": "BuiltinCommonInstructions::JsCode", - "inlineCode": [ - "/** @type {gdjs.CustomRuntimeObject3D} */", - "const object = objects[0];", - "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", - " child.deleteFromScene();", - "}", - "if (object.__terrain3D) object.__terrain3D.dispose();", - "object.__terrain3D = new gdjs.__terrain3DExtension.Terrain(object);" + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, + "keepRatio": true, + "layer": "", + "name": "AreaPlaceholder", + "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, + "x": 0, + "y": 0, + "zOrder": 1, + "numberProperties": [], + "stringProperties": [], + "initialVariables": [] + } + ], + "editionSettings": {}, + "eventsFunctions": [ + { + "fullName": "", + "functionType": "Action", + "name": "onCreated", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Terrain3D::DefineHelperClasses" + }, + "parameters": [ + "", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", + " child.deleteFromScene();", + "}", + "if (object.__terrain3D) object.__terrain3D.dispose();", + "object.__terrain3D = new gdjs.__terrain3DExtension.Terrain(object);" ], "parameterObjects": "Object", "useStrict": true, @@ -35438,8 +35886,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -35466,8 +35914,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -35494,8 +35942,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -35522,15 +35970,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -35554,8 +36004,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -35569,9 +36019,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -35579,13 +36029,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -35611,8 +36061,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -35636,9 +36086,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -35646,13 +36096,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -35676,8 +36126,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -35691,9 +36141,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -35701,13 +36151,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -35731,8 +36181,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -35746,9 +36196,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -35756,13 +36206,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -35786,8 +36236,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -35801,9 +36251,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -35811,13 +36261,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -35843,8 +36293,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -35868,9 +36318,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -35878,13 +36328,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -35908,8 +36358,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -35923,9 +36373,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -35933,13 +36383,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -35963,8 +36413,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -35978,9 +36428,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -35988,13 +36438,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -36012,12 +36462,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -36030,16 +36483,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -36057,12 +36507,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -36075,16 +36528,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -36107,8 +36557,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -36126,13 +36576,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -36155,8 +36605,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -36174,13 +36624,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -36206,8 +36656,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -36240,13 +36690,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -36268,8 +36718,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -36282,13 +36732,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -36307,12 +36757,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -36326,21 +36779,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -36362,29 +36812,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -36405,23 +36855,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -36441,8 +36891,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -36450,13 +36900,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -36474,12 +36924,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -36492,16 +36945,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -36519,12 +36969,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -36537,16 +36990,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -36564,12 +37014,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -36582,14 +37035,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -36634,6 +37187,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -36691,6 +37245,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -36702,6 +37257,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -36720,6 +37276,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -36731,6 +37288,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -36749,6 +37307,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -36760,6 +37319,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -36778,6 +37338,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -36789,6 +37350,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -36802,8 +37364,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -36883,8 +37445,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -36902,6 +37464,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", @@ -36910,7 +37503,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -36922,6 +37631,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -37009,16 +37719,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -37083,8 +37792,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -37111,8 +37820,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -37147,8 +37856,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -37175,13 +37884,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -37194,8 +37920,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -37203,8 +37929,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -37263,8 +37989,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -37295,13 +38021,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -37310,10 +38098,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -37323,6 +38112,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -37401,23 +38191,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -37475,8 +38280,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -37503,8 +38308,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -37535,8 +38340,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -37563,15 +38368,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -37594,8 +38401,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -37613,13 +38420,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -37642,8 +38449,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -37661,13 +38468,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -37676,32 +38483,124 @@ "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", "const object = objects[0];", - "eventsFunctionContext.returnValue = object.__terrainScatter.getCopyCount();" + "eventsFunctionContext.returnValue = object.__terrainScatter.getCopyCount();" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -37712,19 +38611,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -37737,18 +38639,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -37758,19 +38657,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -37783,20 +38685,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -37804,19 +38704,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -37829,20 +38732,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -37850,19 +38751,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -37875,33 +38779,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -38002,8 +39002,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -38011,8 +39011,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -38020,8 +39020,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -38037,17 +39037,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -38083,7 +39083,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] } diff --git a/examples/starting-expanded-first-person-shooter-enemies/starting-expanded-first-person-shooter-enemies.json b/examples/starting-expanded-first-person-shooter-enemies/starting-expanded-first-person-shooter-enemies.json index 313053e1b..12d41cb6b 100644 --- a/examples/starting-expanded-first-person-shooter-enemies/starting-expanded-first-person-shooter-enemies.json +++ b/examples/starting-expanded-first-person-shooter-enemies/starting-expanded-first-person-shooter-enemies.json @@ -42455,7 +42455,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.3.1", "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.", "", @@ -42491,14 +42491,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -43553,8 +43561,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;", @@ -43563,11 +43580,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;", @@ -43576,15 +43593,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. */", @@ -43596,15 +43611,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", @@ -43622,15 +43638,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;", @@ -43641,48 +43653,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 +", @@ -43714,26 +43739,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;", @@ -43754,7 +43783,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", @@ -43762,12 +43814,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;", @@ -43775,13 +43833,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.", @@ -43840,11 +43956,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 },", @@ -43853,6 +43972,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.", @@ -43872,8 +43992,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());", @@ -43910,11 +44034,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 */", @@ -44146,16 +44287,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());", @@ -45073,40 +45236,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;", "};", "", @@ -45412,14 +45556,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -45757,18 +45901,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -46000,18 +46144,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -46022,19 +46187,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -46063,17 +46231,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -46100,8 +46277,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -46195,11 +46375,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -46212,7 +46397,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -46240,14 +46426,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -46295,9 +46484,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -46316,6 +46510,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -46428,9 +46623,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -46485,12 +46680,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -46664,8 +46859,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -46962,163 +47160,413 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 - } - ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, - "keepRatio": true, - "layer": "", - "name": "AreaPlaceholder", - "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", - "x": 0, - "y": 0, - "z": 0, - "zOrder": 1, - "numberProperties": [], - "stringProperties": [], - "initialVariables": [] - } - ], - "editionSettings": {}, - "eventsFunctions": [ - { - "fullName": "", - "functionType": "Action", - "name": "onCreated", - "sentence": "", - "events": [ + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, { - "type": "BuiltinCommonInstructions::Standard", - "conditions": [], - "actions": [ - { - "type": { - "value": "Terrain3D::DefineHelperClasses" - }, - "parameters": [ - "", - "" - ] - } + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" ] }, { - "type": "BuiltinCommonInstructions::JsCode", - "inlineCode": [ - "/** @type {gdjs.CustomRuntimeObject3D} */", - "const object = objects[0];", - "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", - " child.deleteFromScene();", - "}", - "if (object.__terrain3D) object.__terrain3D.dispose();", + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, + "keepRatio": true, + "layer": "", + "name": "AreaPlaceholder", + "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, + "x": 0, + "y": 0, + "zOrder": 1, + "numberProperties": [], + "stringProperties": [], + "initialVariables": [] + } + ], + "editionSettings": {}, + "eventsFunctions": [ + { + "fullName": "", + "functionType": "Action", + "name": "onCreated", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Terrain3D::DefineHelperClasses" + }, + "parameters": [ + "", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", + " child.deleteFromScene();", + "}", + "if (object.__terrain3D) object.__terrain3D.dispose();", "object.__terrain3D = new gdjs.__terrain3DExtension.Terrain(object);" ], "parameterObjects": "Object", @@ -47130,8 +47578,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -47158,8 +47606,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -47186,8 +47634,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -47214,15 +47662,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -47246,8 +47696,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -47261,9 +47711,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -47271,13 +47721,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -47303,8 +47753,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -47328,9 +47778,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -47338,13 +47788,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -47368,8 +47818,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -47383,9 +47833,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -47393,13 +47843,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -47423,8 +47873,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -47438,9 +47888,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -47448,13 +47898,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -47478,8 +47928,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -47493,9 +47943,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -47503,13 +47953,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -47535,8 +47985,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -47560,9 +48010,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -47570,13 +48020,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -47600,8 +48050,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -47615,9 +48065,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -47625,13 +48075,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -47655,8 +48105,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -47670,9 +48120,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -47680,13 +48130,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -47704,12 +48154,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -47722,16 +48175,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -47749,12 +48199,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -47767,16 +48220,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -47799,8 +48249,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -47818,13 +48268,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -47847,8 +48297,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -47866,13 +48316,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -47898,8 +48348,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -47932,13 +48382,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -47960,8 +48410,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -47974,13 +48424,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -47999,12 +48449,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -48018,21 +48471,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -48054,29 +48504,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -48097,23 +48547,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -48133,8 +48583,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -48142,13 +48592,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -48166,12 +48616,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -48184,16 +48637,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -48211,12 +48661,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -48229,16 +48682,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -48256,12 +48706,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -48274,14 +48727,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -48326,6 +48879,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -48383,6 +48937,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -48394,6 +48949,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -48412,6 +48968,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -48423,6 +48980,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -48441,6 +48999,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -48452,6 +49011,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -48470,6 +49030,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -48481,6 +49042,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -48494,8 +49056,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -48575,8 +49137,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -48594,6 +49156,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", @@ -48602,7 +49195,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -48614,6 +49323,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -48701,16 +49411,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -48775,8 +49484,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -48803,8 +49512,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -48839,8 +49548,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -48867,13 +49576,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -48886,8 +49612,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -48895,8 +49621,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -48955,8 +49681,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -48987,13 +49713,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -49002,10 +49790,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -49015,6 +49804,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -49093,23 +49883,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -49167,8 +49972,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -49195,8 +50000,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -49227,8 +50032,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -49255,15 +50060,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -49286,8 +50093,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -49305,13 +50112,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -49334,8 +50141,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -49353,13 +50160,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -49368,32 +50175,124 @@ "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", "const object = objects[0];", - "eventsFunctionContext.returnValue = object.__terrainScatter.getCopyCount();" + "eventsFunctionContext.returnValue = object.__terrainScatter.getCopyCount();" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -49404,19 +50303,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -49429,18 +50331,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -49450,19 +50349,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -49475,20 +50377,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -49496,19 +50396,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -49521,20 +50424,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -49542,19 +50443,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -49567,33 +50471,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -49694,8 +50694,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -49703,8 +50703,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -49712,8 +50712,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -49729,17 +50729,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -49775,7 +50775,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] } diff --git a/examples/starting-expanded-first-person-shooter-horror/starting-expanded-first-person-shooter-horror.json b/examples/starting-expanded-first-person-shooter-horror/starting-expanded-first-person-shooter-horror.json index 7a3d7979e..6a1db37a2 100644 --- a/examples/starting-expanded-first-person-shooter-horror/starting-expanded-first-person-shooter-horror.json +++ b/examples/starting-expanded-first-person-shooter-horror/starting-expanded-first-person-shooter-horror.json @@ -48016,7 +48016,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.3.1", "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.", "", @@ -48052,14 +48052,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -49114,8 +49122,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;", @@ -49124,11 +49141,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;", @@ -49137,15 +49154,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. */", @@ -49157,15 +49172,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", @@ -49183,15 +49199,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;", @@ -49202,48 +49214,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 +", @@ -49275,26 +49300,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;", @@ -49315,7 +49344,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", @@ -49323,12 +49375,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;", @@ -49336,13 +49394,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.", @@ -49401,11 +49517,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 },", @@ -49414,6 +49533,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.", @@ -49433,8 +49553,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());", @@ -49471,11 +49595,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 */", @@ -49707,16 +49848,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());", @@ -50634,40 +50797,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;", "};", "", @@ -50973,14 +51117,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -51318,18 +51462,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -51561,18 +51705,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -51583,19 +51748,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -51624,17 +51792,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -51661,8 +51838,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -51756,11 +51936,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -51773,7 +51958,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -51801,14 +51987,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -51856,9 +52045,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -51877,6 +52071,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -51989,9 +52184,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -52046,12 +52241,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -52225,8 +52420,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -52523,163 +52721,413 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 - } - ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, - "keepRatio": true, - "layer": "", - "name": "AreaPlaceholder", - "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", - "x": 0, - "y": 0, - "z": 0, - "zOrder": 1, - "numberProperties": [], - "stringProperties": [], - "initialVariables": [] - } - ], - "editionSettings": {}, - "eventsFunctions": [ - { - "fullName": "", - "functionType": "Action", - "name": "onCreated", - "sentence": "", - "events": [ + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, { - "type": "BuiltinCommonInstructions::Standard", - "conditions": [], - "actions": [ - { - "type": { - "value": "Terrain3D::DefineHelperClasses" - }, - "parameters": [ - "", - "" - ] - } + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" ] }, { - "type": "BuiltinCommonInstructions::JsCode", - "inlineCode": [ - "/** @type {gdjs.CustomRuntimeObject3D} */", - "const object = objects[0];", - "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", - " child.deleteFromScene();", - "}", - "if (object.__terrain3D) object.__terrain3D.dispose();", + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, + "keepRatio": true, + "layer": "", + "name": "AreaPlaceholder", + "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, + "x": 0, + "y": 0, + "zOrder": 1, + "numberProperties": [], + "stringProperties": [], + "initialVariables": [] + } + ], + "editionSettings": {}, + "eventsFunctions": [ + { + "fullName": "", + "functionType": "Action", + "name": "onCreated", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Terrain3D::DefineHelperClasses" + }, + "parameters": [ + "", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", + " child.deleteFromScene();", + "}", + "if (object.__terrain3D) object.__terrain3D.dispose();", "object.__terrain3D = new gdjs.__terrain3DExtension.Terrain(object);" ], "parameterObjects": "Object", @@ -52691,8 +53139,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -52719,8 +53167,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -52747,8 +53195,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -52775,15 +53223,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -52807,8 +53257,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -52822,9 +53272,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -52832,13 +53282,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -52864,8 +53314,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -52889,9 +53339,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -52899,13 +53349,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -52929,8 +53379,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -52944,9 +53394,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -52954,13 +53404,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -52984,8 +53434,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -52999,9 +53449,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -53009,13 +53459,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -53039,8 +53489,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -53054,9 +53504,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -53064,13 +53514,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -53096,8 +53546,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -53121,9 +53571,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -53131,13 +53581,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -53161,8 +53611,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -53176,9 +53626,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -53186,13 +53636,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -53216,8 +53666,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -53231,9 +53681,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -53241,13 +53691,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -53265,12 +53715,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -53283,16 +53736,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -53310,12 +53760,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -53328,16 +53781,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -53360,8 +53810,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -53379,13 +53829,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -53408,8 +53858,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -53427,13 +53877,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -53459,8 +53909,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -53493,13 +53943,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -53521,8 +53971,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -53535,13 +53985,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -53560,12 +54010,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -53579,21 +54032,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -53615,29 +54065,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -53658,23 +54108,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -53694,8 +54144,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -53703,13 +54153,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -53727,12 +54177,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -53745,16 +54198,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -53772,12 +54222,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -53790,16 +54243,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -53817,12 +54267,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -53835,14 +54288,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -53887,6 +54440,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -53944,6 +54498,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -53955,6 +54510,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -53973,6 +54529,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -53984,6 +54541,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -54002,6 +54560,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -54013,6 +54572,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -54031,6 +54591,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -54042,6 +54603,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -54055,8 +54617,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -54136,8 +54698,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -54155,6 +54717,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", @@ -54163,7 +54756,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -54175,6 +54884,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -54262,16 +54972,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -54336,8 +55045,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -54364,8 +55073,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -54400,8 +55109,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -54428,13 +55137,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -54447,8 +55173,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -54456,8 +55182,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -54516,8 +55242,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -54548,13 +55274,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -54563,10 +55351,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -54576,6 +55365,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -54654,23 +55444,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -54728,8 +55533,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -54756,8 +55561,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -54788,8 +55593,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -54816,15 +55621,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -54847,8 +55654,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -54866,13 +55673,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -54895,8 +55702,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -54914,13 +55721,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -54929,32 +55736,124 @@ "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", "const object = objects[0];", - "eventsFunctionContext.returnValue = object.__terrainScatter.getCopyCount();" + "eventsFunctionContext.returnValue = object.__terrainScatter.getCopyCount();" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -54965,19 +55864,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -54990,18 +55892,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -55011,19 +55910,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -55036,20 +55938,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -55057,19 +55957,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -55082,20 +55985,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -55103,19 +56004,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -55128,33 +56032,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -55255,8 +56255,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -55264,8 +56264,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -55273,8 +56273,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -55290,17 +56290,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -55336,7 +56336,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] } diff --git a/examples/starting-expanded-first-person-survival-crafting/starting-expanded-first-person-survival-crafting.json b/examples/starting-expanded-first-person-survival-crafting/starting-expanded-first-person-survival-crafting.json index 00a46487a..d1ea8b7e3 100644 --- a/examples/starting-expanded-first-person-survival-crafting/starting-expanded-first-person-survival-crafting.json +++ b/examples/starting-expanded-first-person-survival-crafting/starting-expanded-first-person-survival-crafting.json @@ -37709,7 +37709,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.3.1", "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.", "", @@ -37745,14 +37745,22 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], "eventsFunctions": [ { + "description": "Define helper classes JavaScript code.", "fullName": "Define helper classes", "functionType": "Action", "name": "DefineHelperClasses", + "private": true, "sentence": "Define helper classes JavaScript code", "events": [ { @@ -38807,8 +38815,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;", @@ -38817,11 +38834,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;", @@ -38830,15 +38847,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. */", @@ -38850,15 +38865,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", @@ -38876,15 +38892,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;", @@ -38895,48 +38907,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 +", @@ -38968,26 +38993,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;", @@ -39008,7 +39037,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", @@ -39016,12 +39068,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;", @@ -39029,13 +39087,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.", @@ -39094,11 +39210,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 },", @@ -39107,6 +39226,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.", @@ -39126,8 +39246,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());", @@ -39164,11 +39288,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 */", @@ -39400,16 +39541,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());", @@ -40327,40 +40490,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;", "};", "", @@ -40666,14 +40810,14 @@ } ], "parameters": [], - "objectGroups": [], - "description": "Define helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define grass helper classes JavaScript code.", "fullName": "Define grass helper classes", "functionType": "Action", "name": "DefineGrassHelperClasses", + "private": true, "sentence": "Define grass helper classes JavaScript code", "events": [ { @@ -41011,18 +41155,18 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define grass helper classes JavaScript code.", - "private": true + "objectGroups": [] }, { + "description": "Define scattered models helper classes JavaScript code.", "fullName": "Define scattered models helper classes", "functionType": "Action", "name": "DefineScatterHelperClasses", + "private": true, "sentence": "Define scattered models helper classes JavaScript code", "events": [ { @@ -41254,18 +41398,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -41276,19 +41441,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -41317,17 +41485,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -41354,8 +41531,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -41449,11 +41629,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -41466,7 +41651,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -41494,14 +41680,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -41549,9 +41738,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -41570,6 +41764,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -41682,9 +41877,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -41739,12 +41934,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -41918,8 +42113,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -42216,163 +42414,413 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], - "objectGroups": [], - "description": "Define scattered models helper classes JavaScript code.", - "private": true - } - ], - "eventsBasedBehaviors": [], - "eventsBasedObjects": [ + "objectGroups": [] + }, { - "areaMaxX": 4096, - "areaMaxY": 4096, - "areaMaxZ": 600, - "areaMinX": 0, - "areaMinY": 0, - "areaMinZ": 0, - "defaultName": "Terrain", - "assetStoreTag": "3d terrains", - "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", - "fullName": "3D terrain", - "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", - "is3D": true, - "isUsingLegacyInstancesRenderer": true, - "name": "Terrain3D", - "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", - "objects": [ - { - "assetStoreId": "", - "name": "AreaPlaceholder", - "type": "Scene3D::Cube3DObject", - "variables": [], - "effects": [], - "behaviors": [], - "content": { - "width": 4096, - "height": 4096, - "depth": 600, - "enableTextureTransparency": false, - "facesOrientation": "Y", - "backFaceUpThroughWhichAxisRotation": "X", - "materialType": "Basic", - "tint": "255;255;255", - "frontFaceResourceName": "", - "frontFaceVisible": false, - "frontFaceResourceRepeat": false, - "backFaceResourceName": "", - "backFaceVisible": false, - "backFaceResourceRepeat": false, - "leftFaceResourceName": "", - "leftFaceVisible": false, - "leftFaceResourceRepeat": false, - "rightFaceResourceName": "", - "rightFaceVisible": false, - "rightFaceResourceRepeat": false, - "topFaceResourceName": "", - "topFaceVisible": false, - "topFaceResourceRepeat": false, - "bottomFaceResourceName": "", - "bottomFaceVisible": false, - "bottomFaceResourceRepeat": false - } - } - ], - "objectsFolderStructure": { - "folderName": "__ROOT", - "children": [ - { - "objectName": "AreaPlaceholder" - } - ] - }, - "objectsGroups": [], - "layers": [ + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ { - "ambientLightColorB": 200, - "ambientLightColorG": 200, - "ambientLightColorR": 200, - "camera2DPlaneMaxDrawingDistance": 5000, - "camera3DFarPlaneDistance": 10000, - "camera3DFieldOfView": 45, - "camera3DNearPlaneDistance": 3, - "cameraType": "", - "followBaseLayerCamera": false, - "isLightingLayer": false, - "isLocked": false, - "name": "", - "renderingType": "", - "visibility": true, - "cameras": [ + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ { - "defaultSize": true, - "defaultViewport": true, - "height": 0, - "viewportBottom": 1, - "viewportLeft": 0, - "viewportRight": 1, - "viewportTop": 0, - "width": 0 - } - ], - "effects": [] - } - ], - "instances": [ - { - "angle": 0, - "customSize": true, - "width": 4096, - "height": 4096, - "depth": 600, - "keepRatio": true, - "layer": "", - "name": "AreaPlaceholder", - "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", - "x": 0, - "y": 0, - "z": 0, - "zOrder": 1, - "numberProperties": [], - "stringProperties": [], - "initialVariables": [] - } - ], - "editionSettings": {}, - "eventsFunctions": [ - { - "fullName": "", - "functionType": "Action", - "name": "onCreated", - "sentence": "", - "events": [ + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, { - "type": "BuiltinCommonInstructions::Standard", - "conditions": [], - "actions": [ - { - "type": { - "value": "Terrain3D::DefineHelperClasses" - }, - "parameters": [ - "", - "" - ] - } + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" ] }, { - "type": "BuiltinCommonInstructions::JsCode", - "inlineCode": [ - "/** @type {gdjs.CustomRuntimeObject3D} */", - "const object = objects[0];", - "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", - " child.deleteFromScene();", - "}", - "if (object.__terrain3D) object.__terrain3D.dispose();", + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] + } + ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "DefineHelperClasses" + }, + { + "functionName": "DefineGrassHelperClasses" + }, + { + "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" + } + ] + }, + "eventsBasedBehaviors": [], + "eventsBasedObjects": [ + { + "areaMaxX": 4096, + "areaMaxY": 4096, + "areaMaxZ": 600, + "areaMinX": 0, + "areaMinY": 0, + "areaMinZ": 0, + "assetStoreTag": "3d terrains", + "defaultName": "Terrain", + "description": "A ground with hills, mountains or valleys that can be sculpted and painted in the scene editor, or changed with events. Add the 3D physics behavior to it to make objects walk or roll on it.", + "fullName": "3D terrain", + "helpPath": "", + "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiBpZD0ibWRpLXRlcnJhaW4iIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTQsNkwxMC4yNSwxMUwxMy4xLDE0LjhMMTEuNSwxNkM5LjgxLDEzLjc1IDcsMTAgNywxMEwxLDE4SDIzTDE0LDZaIiAvPjwvc3ZnPg==", + "is3D": true, + "isUsingLegacyInstancesRenderer": true, + "name": "Terrain3D", + "previewIconUrl": "https://asset-resources.gdevelop.io/public-resources/Icons/10e0a26c0d500830dfe23b94138beb0ef61607ba62bca05a5aa5c849777f1b06_terrain.svg", + "objects": [ + { + "assetStoreId": "", + "name": "AreaPlaceholder", + "type": "Scene3D::Cube3DObject", + "variables": [], + "effects": [], + "behaviors": [], + "content": { + "width": 4096, + "height": 4096, + "depth": 600, + "enableTextureTransparency": false, + "facesOrientation": "Y", + "backFaceUpThroughWhichAxisRotation": "X", + "materialType": "Basic", + "tint": "255;255;255", + "frontFaceResourceName": "", + "frontFaceVisible": false, + "frontFaceResourceRepeat": false, + "backFaceResourceName": "", + "backFaceVisible": false, + "backFaceResourceRepeat": false, + "leftFaceResourceName": "", + "leftFaceVisible": false, + "leftFaceResourceRepeat": false, + "rightFaceResourceName": "", + "rightFaceVisible": false, + "rightFaceResourceRepeat": false, + "topFaceResourceName": "", + "topFaceVisible": false, + "topFaceResourceRepeat": false, + "bottomFaceResourceName": "", + "bottomFaceVisible": false, + "bottomFaceResourceRepeat": false + } + } + ], + "objectsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "objectName": "AreaPlaceholder" + } + ] + }, + "objectsGroups": [], + "layers": [ + { + "ambientLightColorB": 200, + "ambientLightColorG": 200, + "ambientLightColorR": 200, + "camera2DPlaneMaxDrawingDistance": 5000, + "camera3DFarPlaneDistance": 10000, + "camera3DFieldOfView": 45, + "camera3DNearPlaneDistance": 3, + "cameraType": "", + "followBaseLayerCamera": false, + "isLightingLayer": false, + "isLocked": false, + "name": "", + "renderingType": "", + "visibility": true, + "cameras": [ + { + "defaultSize": true, + "defaultViewport": true, + "height": 0, + "viewportBottom": 1, + "viewportLeft": 0, + "viewportRight": 1, + "viewportTop": 0, + "width": 0 + } + ], + "effects": [] + } + ], + "instances": [ + { + "angle": 0, + "customSize": true, + "depth": 600, + "height": 4096, + "keepRatio": true, + "layer": "", + "name": "AreaPlaceholder", + "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-004096004096", + "width": 4096, + "x": 0, + "y": 0, + "zOrder": 1, + "numberProperties": [], + "stringProperties": [], + "initialVariables": [] + } + ], + "editionSettings": {}, + "eventsFunctions": [ + { + "fullName": "", + "functionType": "Action", + "name": "onCreated", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Terrain3D::DefineHelperClasses" + }, + "parameters": [ + "", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "for (const child of object.getChildrenContainer().getAdhocListOfAllInstances()) {", + " child.deleteFromScene();", + "}", + "if (object.__terrain3D) object.__terrain3D.dispose();", "object.__terrain3D = new gdjs.__terrain3DExtension.Terrain(object);" ], "parameterObjects": "Object", @@ -42384,8 +42832,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -42412,8 +42860,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -42440,8 +42888,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] @@ -42468,15 +42916,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" } ], "objectGroups": [] }, { + "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", "fullName": "Raise or lower the ground", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGround", "sentence": "Raise the ground of _PARAM0_ by _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -42500,8 +42950,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -42515,9 +42965,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -42525,13 +42975,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground around a position (or lower it with a negative height). It's raised the most at the center and less and less up to the radius.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", "fullName": "Raise or lower the ground along a line", "functionType": "Action", + "group": "Sculpt", "name": "RaiseGroundAlongLine", "sentence": "Raise the ground of _PARAM0_ by _PARAM6_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -42557,8 +43007,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -42582,9 +43032,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Height to add (negative to lower the ground)", @@ -42592,13 +43042,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Raise the ground along a line, for example for a ridge, or lower it with a negative height to dig a river bed or a valley.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Flatten the ground around a position to a given height, for example to place a building.", "fullName": "Flatten the ground", "functionType": "Action", + "group": "Sculpt", "name": "FlattenGround", "sentence": "Flatten the ground of _PARAM0_ to a Z of _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -42622,8 +43072,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -42637,9 +43087,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Z position of the flat ground", @@ -42647,13 +43097,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Flatten the ground around a position to a given height, for example to place a building.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Smooth the ground around a position, softening bumps and cliffs.", "fullName": "Smooth the ground", "functionType": "Action", + "group": "Sculpt", "name": "SmoothGround", "sentence": "Smooth the ground of _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_ (strength: _PARAM4_)", "events": [ @@ -42677,8 +43127,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -42692,9 +43142,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Strength (between 0 and 1)", @@ -42702,13 +43152,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Smooth the ground around a position, softening bumps and cliffs.", - "group": "Sculpt" + "objectGroups": [] }, { + "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", "fullName": "Paint a layer", "functionType": "Action", + "group": "Paint", "name": "PaintLayer", "sentence": "Paint layer _PARAM4_ on _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -42732,8 +43182,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -42747,9 +43197,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -42757,13 +43207,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer (like grass, dirt, rock or sand) on the ground around a position.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Paint a layer on the ground along a line, for example for a path or a road.", "fullName": "Paint a layer along a line", "functionType": "Action", + "group": "Paint", "name": "PaintLayerAlongLine", "sentence": "Paint layer _PARAM6_ on _PARAM0_ from _PARAM1_; _PARAM2_ to _PARAM3_; _PARAM4_ with a radius of _PARAM5_", "events": [ @@ -42789,8 +43239,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the start", @@ -42814,9 +43264,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance from the line.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance from the line." + "type": "expression" }, { "description": "Layer (1 to 4)", @@ -42824,13 +43274,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint a layer on the ground along a line, for example for a path or a road.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", "fullName": "Tint the ground", "functionType": "Action", + "group": "Paint", "name": "TintGround", "sentence": "Tint the ground of _PARAM0_ with _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -42854,8 +43304,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -42869,9 +43319,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Color", @@ -42879,13 +43329,13 @@ "type": "color" } ], - "objectGroups": [], - "description": "Tint the ground around a position with a color multiplying its layers, like dry grass or darker mud. White removes the tint.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", "fullName": "Change the wetness of the ground", "functionType": "Action", + "group": "Paint", "name": "WetGround", "sentence": "Change the wetness of the ground of _PARAM0_ to _PARAM4_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -42909,8 +43359,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -42924,9 +43374,9 @@ }, { "description": "Radius", + "longDescription": "The effect fades out smoothly up to this distance.", "name": "Radius", - "type": "expression", - "longDescription": "The effect fades out smoothly up to this distance." + "type": "expression" }, { "description": "Wetness (from 0 for dry to 1)", @@ -42934,13 +43384,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the ground around a position wet (darker and shiny, like after rain or near water) or dry.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", "fullName": "Ground wetness", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundWetness", "sentence": "the wetness of the ground at _PARAM1_; _PARAM2_", "events": [ @@ -42958,12 +43408,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -42976,16 +43429,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the wetness of the ground at a position, from 0 (dry) to 1, painted or near the water (wet shore).", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", "fullName": "Ground tint", "functionType": "StringExpression", + "group": "Paint", "name": "GroundTint", "sentence": "", "events": [ @@ -43003,12 +43453,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "string" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -43021,16 +43474,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The tint painted on the ground at a position, like \"255;255;255\" (white when there is none).", - "group": "Paint", - "expressionType": { - "type": "string" - } + "objectGroups": [] }, { + "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", "fullName": "Make a hole", "functionType": "Action", + "group": "Holes", "name": "MakeHole", "sentence": "Make a hole in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -43053,8 +43503,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -43072,13 +43522,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the ground in a circle, for the entrance of a tunnel, a cave or a well: there is nothing drawn and no collision there. Fill it again with the \"Fill holes\" action. A height field can't go under itself: put a 3D model (with a collision shape) for the tunnel or the cave under the ground. The physics engine removes the triangles touching a hole, so the hole in the collisions can be up to a cell larger than the one drawn: make the model go a bit further than the hole, or put rocks around the entrance.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", "fullName": "Fill holes", "functionType": "Action", + "group": "Holes", "name": "FillHoles", "sentence": "Fill the holes in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -43101,8 +43551,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -43120,13 +43570,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Fill the holes in a circle with the layers of the ground around them: the ground is drawn and has collisions again.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", "fullName": "Carve an entrance", "functionType": "Action", + "group": "Holes", "name": "CarveEntrance", "sentence": "Carve an entrance in _PARAM0_ at _PARAM1_; _PARAM2_ with a radius of _PARAM3_, going in at _PARAM4_° with its floor at Z _PARAM5_ and _PARAM6_ pixels of flat ground in front", "events": [ @@ -43152,8 +43602,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position of the center", @@ -43186,13 +43636,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Make the entrance of a tunnel or a cave on a slope: a hole, with the ground in front of it flattened to the floor of the tunnel.", - "group": "Holes" + "objectGroups": [] }, { + "description": "Check if there is a hole in the ground at a position.", "fullName": "Hole", "functionType": "Condition", + "group": "Holes", "name": "IsHole", "sentence": "There is a hole in _PARAM0_ at _PARAM1_; _PARAM2_", "events": [ @@ -43214,8 +43664,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -43228,13 +43678,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Check if there is a hole in the ground at a position.", - "group": "Holes" + "objectGroups": [] }, { + "description": "The Z position of the ground at a position, or the given value where there is a hole.", "fullName": "Ground Z position, with holes", "functionType": "Expression", + "group": "Holes", "name": "GroundZWithHoles", "sentence": "", "events": [ @@ -43253,12 +43703,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -43272,21 +43725,18 @@ }, { "description": "Value in holes", + "longDescription": "For example, a Z position far under the ground.", "name": "ZInHoles", - "type": "expression", - "longDescription": "For example, a Z position far under the ground." + "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the ground at a position, or the given value where there is a hole.", - "group": "Holes", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "Replace the ground with a generated relief. Painted layers are reset.", "fullName": "Generate a relief", "functionType": "Action", + "group": "Generation", "name": "GenerateRelief", "sentence": "Generate a relief of _PARAM1_ on _PARAM0_ with seed _PARAM2_", "events": [ @@ -43308,29 +43758,29 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Relief", "name": "Relief", - "type": "stringWithSelector", - "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]" + "supplementaryInformation": "[\"Flat\", \"Hills\", \"Mountains\", \"Island\"]", + "type": "stringWithSelector" }, { "description": "Seed", + "longDescription": "Each seed gives a different terrain with the same relief.", "name": "Seed", - "type": "expression", - "longDescription": "Each seed gives a different terrain with the same relief." + "type": "expression" } ], - "objectGroups": [], - "description": "Replace the ground with a generated relief. Painted layers are reset.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Replace the ground with the heights of an image. Painted layers are kept.", "fullName": "Load a heightmap", "functionType": "Action", + "group": "Generation", "name": "LoadHeightmap", "sentence": "Load the heights of _PARAM0_ from _PARAM1_", "events": [ @@ -43351,23 +43801,23 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Heightmap image", + "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels.", "name": "Image", - "type": "imageResource", - "longDescription": "Black is the lowest, white the highest. For more precision, store the height on 16 bits in the red (most significant byte) and green channels." + "type": "imageResource" } ], - "objectGroups": [], - "description": "Replace the ground with the heights of an image. Painted layers are kept.", - "group": "Generation" + "objectGroups": [] }, { + "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", "fullName": "Put objects on the ground", "functionType": "Action", + "group": "Position", "name": "PutOnGround", "sentence": "Put _PARAM1_ on the ground of _PARAM0_", "events": [ @@ -43387,8 +43837,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "Objects to put on the ground", @@ -43396,13 +43846,13 @@ "type": "objectList" } ], - "objectGroups": [], - "description": "Move objects up or down so that their bottom is on the ground, under their center. Useful to place trees, rocks or characters on a terrain.", - "group": "Position" + "objectGroups": [] }, { + "description": "the Z position of the ground at a position of the scene.", "fullName": "Ground Z position", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundZ", "sentence": "the ground Z position at _PARAM1_; _PARAM2_", "events": [ @@ -43420,12 +43870,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -43438,16 +43891,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the Z position of the ground at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", "fullName": "Ground slope", "functionType": "ExpressionAndCondition", + "group": "Position", "name": "GroundSlope", "sentence": "the ground slope at _PARAM1_; _PARAM2_", "events": [ @@ -43465,12 +43915,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -43483,16 +43936,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the slope of the ground (angle with the horizontal, in degrees) at a position of the scene.", - "group": "Position", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { + "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", "fullName": "Ground layer", "functionType": "ExpressionAndCondition", + "group": "Paint", "name": "GroundLayer", "sentence": "the ground layer at _PARAM1_; _PARAM2_", "events": [ @@ -43510,12 +43960,15 @@ "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::Terrain3D" + "supplementaryInformation": "Terrain3D::Terrain3D", + "type": "object" }, { "description": "X position", @@ -43528,14 +43981,114 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the layer (from 1 to 4) painted the most at a position of the scene. Useful to play footstep sounds or to slow down characters in water or mud.", - "group": "Paint", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Sculpt", + "children": [ + { + "functionName": "RaiseGround" + }, + { + "functionName": "RaiseGroundAlongLine" + }, + { + "functionName": "FlattenGround" + }, + { + "functionName": "SmoothGround" + } + ] + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "PaintLayer" + }, + { + "functionName": "PaintLayerAlongLine" + }, + { + "functionName": "TintGround" + }, + { + "functionName": "WetGround" + }, + { + "functionName": "GroundWetness" + }, + { + "functionName": "GroundTint" + }, + { + "functionName": "GroundLayer" + } + ] + }, + { + "folderName": "Holes", + "children": [ + { + "functionName": "MakeHole" + }, + { + "functionName": "FillHoles" + }, + { + "functionName": "CarveEntrance" + }, + { + "functionName": "IsHole" + }, + { + "functionName": "GroundZWithHoles" + } + ] + }, + { + "folderName": "Generation", + "children": [ + { + "functionName": "GenerateRelief" + }, + { + "functionName": "LoadHeightmap" + } + ] + }, + { + "folderName": "Position", + "children": [ + { + "functionName": "PutOnGround" + }, + { + "functionName": "GroundZ" + }, + { + "functionName": "GroundSlope" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "Hills", @@ -43580,6 +44133,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "HeightmapImage" }, { @@ -43637,6 +44191,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1Texture" }, { @@ -43648,6 +44203,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer1NormalMap" }, { @@ -43666,6 +44222,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2Texture" }, { @@ -43677,6 +44234,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer2NormalMap" }, { @@ -43695,6 +44253,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3Texture" }, { @@ -43706,6 +44265,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer3NormalMap" }, { @@ -43724,6 +44284,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4Texture" }, { @@ -43735,6 +44296,7 @@ "extraInformation": [ "image" ], + "choices": [], "name": "Layer4NormalMap" }, { @@ -43748,8 +44310,8 @@ { "value": "256", "type": "Number", - "label": "Texture size", "unit": "Pixel", + "label": "Texture size", "description": "The size, in the scene, of one repetition of the layer textures.", "group": "Layers", "name": "TextureSize" @@ -43829,8 +44391,8 @@ { "value": "45", "type": "Number", - "label": "Steep slope angle", "unit": "DegreeAngle", + "label": "Steep slope angle", "group": "Layers", "name": "CliffAngle" }, @@ -43848,6 +44410,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", @@ -43856,7 +44449,123 @@ "advanced": true, "name": "SculptData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Shape", + "children": [ + { + "propertyName": "Relief" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "HeightmapImage" + }, + { + "propertyName": "Edits" + }, + { + "propertyName": "Resolution" + } + ] + }, + { + "folderName": "Layers", + "children": [ + { + "propertyName": "Layer1Color" + }, + { + "propertyName": "Layer1Texture" + }, + { + "propertyName": "Layer1NormalMap" + }, + { + "propertyName": "Layer2Color" + }, + { + "propertyName": "Layer2Texture" + }, + { + "propertyName": "Layer2NormalMap" + }, + { + "propertyName": "Layer3Color" + }, + { + "propertyName": "Layer3Texture" + }, + { + "propertyName": "Layer3NormalMap" + }, + { + "propertyName": "Layer4Color" + }, + { + "propertyName": "Layer4Texture" + }, + { + "propertyName": "Layer4NormalMap" + }, + { + "propertyName": "NormalMapStrength" + }, + { + "propertyName": "TextureSize" + }, + { + "propertyName": "HeightBlending" + }, + { + "propertyName": "TextureVariation" + }, + { + "propertyName": "CliffLayer" + }, + { + "propertyName": "CliffAngle" + } + ] + }, + { + "folderName": "Wetness", + "children": [ + { + "propertyName": "WetShore" + }, + { + "propertyName": "WetShoreZ" + }, + { + "propertyName": "WetShoreFade" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + }, + { + "propertyName": "Anisotropy" + } + ] + }, + { + "propertyName": "SculptData" + } + ] + }, + "variants": [] }, { "areaMaxX": 1000, @@ -43868,6 +44577,7 @@ "defaultName": "Grass", "description": "A field of grass blades swaying in the wind and pushed aside by characters. They grow in the area of the object, on the highest 3D terrain at each point (one field can cover several terrains), or at the Z position of the object where there is no terrain.", "fullName": "Terrain grass", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0zIDIxYzEtNSAxLTkgMC0xMyAyIDMgMyA3IDMgMTN6bTQgMGMwLTYgMS0xMSA0LTE2LTEgNS0xIDEwIDAgMTZ6bTUgMGMwLTUgMi05IDUtMTItMiA0LTIgOC0xIDEyem01IDBjMS00IDItNiA0LTgtMSAzLTEgNS0xIDh6Ii8+PC9zdmc+", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -43955,16 +44665,15 @@ { "angle": 0, "customSize": true, - "width": 1000, - "height": 1000, "depth": 40, + "height": 1000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-001000001000", + "width": 1000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], @@ -44029,8 +44738,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -44057,8 +44766,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -44093,8 +44802,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] @@ -44121,13 +44830,30 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::TerrainGrass" + "supplementaryInformation": "Terrain3D::TerrainGrass", + "type": "object" } ], "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + } + ] + }, "propertyDescriptors": [ { "value": "60", @@ -44140,8 +44866,8 @@ { "value": "36", "type": "Number", - "label": "Blade height", "unit": "Pixel", + "label": "Blade height", "description": "The average height of blades (each blade is between 60% and 140% of it).", "group": "Blades", "name": "BladeHeight" @@ -44149,8 +44875,8 @@ { "value": "5", "type": "Number", - "label": "Blade width", "unit": "Pixel", + "label": "Blade width", "group": "Blades", "name": "BladeWidth" }, @@ -44209,8 +44935,8 @@ { "value": "40", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No blades grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -44241,13 +44967,75 @@ { "value": "2500", "type": "Number", - "label": "Fade distance", "unit": "Pixel", + "label": "Fade distance", "description": "Blades are less and less dense up to this distance from the camera, and hidden after.", "group": "Rendering", "name": "FadeDistance" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Blades", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "BladeHeight" + }, + { + "propertyName": "BladeWidth" + }, + { + "propertyName": "BaseColor" + }, + { + "propertyName": "TipColor" + }, + { + "propertyName": "Seed" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + } + ] + }, + { + "folderName": "Wind", + "children": [ + { + "propertyName": "WindStrength" + }, + { + "propertyName": "WindSpeed" + }, + { + "propertyName": "BendingObject" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "FadeDistance" + } + ] + } + ] + }, + "variants": [] }, { "areaMaxX": 2000, @@ -44256,10 +45044,11 @@ "areaMinX": 0, "areaMinY": 0, "areaMinZ": 0, - "defaultName": "ScatteredModels", "assetStoreTag": "3d scattered models", + "defaultName": "ScatteredModels", "description": "Many copies of a 3D model (trees, rocks, bushes, flowers...) placed in the area of the object and drawn all at once, which is much faster than an object for each one. They stand on the highest 3D terrain at each point, or at the Z position of the object where there is no terrain. Paint where they grow with the brushes of the scene editor.", "fullName": "Scattered models (forests, rocks...)", + "helpPath": "", "iconUrl": "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxwYXRoIGQ9Ik0xMCAyMXYtM0gzbDUtNUg1bDUtNUg3bDUtNSA1IDVoLTNsNSA1aC0zbDUgNWgtN3YzeiIvPjwvc3ZnPg==", "is3D": true, "isUsingLegacyInstancesRenderer": true, @@ -44269,6 +45058,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -44347,23 +45137,38 @@ { "angle": 0, "customSize": true, - "width": 2000, - "height": 2000, "depth": 200, + "height": 2000, "keepRatio": true, "layer": "", "name": "AreaPlaceholder", "persistentUuid": "5d7a8b1e-3c2f-4e6a-9b1d-002000002000", + "width": 2000, "x": 0, "y": 0, - "z": 0, "zOrder": 1, "numberProperties": [], "stringProperties": [], "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -44421,8 +45226,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -44449,8 +45254,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -44481,8 +45286,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] @@ -44509,15 +45314,17 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" } ], "objectGroups": [] }, { + "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", "fullName": "Add copies", "functionType": "Action", + "group": "Paint", "name": "AddCopies", "sentence": "Add copies of _PARAM0_ around _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -44540,8 +45347,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -44559,13 +45366,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Paint the density of copies around a position, up to the density of the object (copies removed with \"Erase copies\" come back). The effect fades out up to the radius.", - "group": "Paint" + "objectGroups": [] }, { + "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", "fullName": "Erase copies", "functionType": "Action", + "group": "Paint", "name": "EraseCopies", "sentence": "Erase the copies of _PARAM0_ in a circle at _PARAM1_; _PARAM2_ with a radius of _PARAM3_", "events": [ @@ -44588,8 +45395,8 @@ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position of the center", @@ -44607,13 +45414,13 @@ "type": "expression" } ], - "objectGroups": [], - "description": "Remove the copies standing in a circle, for example a tree cut by the player (use the position of the nearest copy and a small radius) or a forest cleared for a building. They come back when the scene is restarted.", - "group": "Paint" + "objectGroups": [] }, { + "description": "the number of copies placed in the area.", "fullName": "Number of copies", "functionType": "ExpressionAndCondition", + "group": "Copies", "name": "CopyCount", "sentence": "the number of copies", "events": [ @@ -44622,32 +45429,124 @@ "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", "const object = objects[0];", - "eventsFunctionContext.returnValue = object.__terrainScatter.getCopyCount();" + "eventsFunctionContext.returnValue = object.__terrainScatter.getCopyCount();" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + } + ], + "objectGroups": [] + }, + { + "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "X position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyX", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.x : x;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", + "fullName": "Y position of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyY", + "sentence": "", + "events": [ + { + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.y : y;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" } ], - "objectGroups": [], - "description": "the number of copies placed in the area.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "X position of the nearest copy", + "description": "The Z position of the base of the copy nearest to a position (on the ground).", + "fullName": "Z position of the nearest copy", "functionType": "Expression", - "name": "NearestCopyX", + "group": "Copies", + "name": "NearestCopyZ", "sentence": "", "events": [ { @@ -44658,19 +45557,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.x : x;" + "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -44683,18 +45585,15 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The X position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Y position of the nearest copy", - "functionType": "Expression", - "name": "NearestCopyY", - "sentence": "", + "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", + "fullName": "Distance to the nearest copy", + "functionType": "ExpressionAndCondition", + "group": "Copies", + "name": "NearestCopyDistance", + "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", "events": [ { "type": "BuiltinCommonInstructions::JsCode", @@ -44704,19 +45603,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.y : y;" + "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -44729,20 +45631,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Y position of the base of the copy nearest to a position (the position itself if there is no copy).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Z position of the nearest copy", + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", "functionType": "Expression", - "name": "NearestCopyZ", + "group": "Copies", + "name": "NearestCopyAngle", "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -44750,19 +45650,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? copy.z : object.getZ();" + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -44775,20 +45678,18 @@ "type": "expression" } ], - "objectGroups": [], - "description": "The Z position of the base of the copy nearest to a position (on the ground).", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] }, { - "fullName": "Distance to the nearest copy", - "functionType": "ExpressionAndCondition", - "name": "NearestCopyDistance", - "sentence": "the distance from _PARAM1_; _PARAM2_ to the nearest copy", + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", "events": [ { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", "type": "BuiltinCommonInstructions::JsCode", "inlineCode": [ "/** @type {gdjs.CustomRuntimeObject3D} */", @@ -44796,19 +45697,22 @@ "const x = eventsFunctionContext.getArgument(\"X\");", "const y = eventsFunctionContext.getArgument(\"Y\");", "const copy = object.__terrainScatter.findNearestCopy(x, y);", - "eventsFunctionContext.returnValue = copy ? Math.hypot(copy.x - x, copy.y - y) : Number.MAX_VALUE;" + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" ], "parameterObjects": "Object", "useStrict": true, "eventsSheetExpanded": false } ], + "expressionType": { + "type": "expression" + }, "parameters": [ { "description": "Object", "name": "Object", - "type": "object", - "supplementaryInformation": "Terrain3D::ScatteredModels" + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" }, { "description": "X position", @@ -44821,33 +45725,129 @@ "type": "expression" } ], - "objectGroups": [], - "description": "the distance (on X and Y) from a position to the base of the nearest copy. It's a very big number when there is no copy.", - "group": "Copies", - "expressionType": { - "type": "expression" - } + "objectGroups": [] } ], + "eventsFunctionsFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "functionName": "onCreated" + }, + { + "functionName": "onDestroy" + }, + { + "functionName": "doStepPostEvents" + }, + { + "functionName": "onHotReloading" + }, + { + "folderName": "Paint", + "children": [ + { + "functionName": "AddCopies" + }, + { + "functionName": "EraseCopies" + } + ] + }, + { + "folderName": "Copies", + "children": [ + { + "functionName": "CopyCount" + }, + { + "functionName": "NearestCopyX" + }, + { + "functionName": "NearestCopyY" + }, + { + "functionName": "NearestCopyZ" + }, + { + "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" + } + ] + } + ] + }, "propertyDescriptors": [ { "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" ], + "choices": [], "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", - "label": "Height of a copy", "unit": "Pixel", - "description": "Copies keep the proportions of the model.", + "label": "Height of a copy", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -44948,8 +45948,8 @@ { "value": "30", "type": "Number", - "label": "Maximum slope", "unit": "DegreeAngle", + "label": "Maximum slope", "description": "No copies grow on steeper ground.", "group": "Ground", "name": "MaxSlope" @@ -44957,8 +45957,8 @@ { "value": "-100000", "type": "Number", - "label": "Minimum ground Z", "unit": "Pixel", + "label": "Minimum ground Z", "description": "No copies grow where the ground is lower (for example, under the water level).", "group": "Ground", "name": "MinGroundZ" @@ -44966,8 +45966,8 @@ { "value": "100000", "type": "Number", - "label": "Maximum ground Z", "unit": "Pixel", + "label": "Maximum ground Z", "description": "No copies grow where the ground is higher (for example, above the tree line).", "group": "Ground", "name": "MaxGroundZ" @@ -44983,17 +45983,17 @@ { "value": "30", "type": "Number", - "label": "Trunk diameter", "unit": "Pixel", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "label": "Trunk diameter", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, { "value": "8000", "type": "Number", - "label": "Draw distance", "unit": "Pixel", + "label": "Draw distance", "description": "Copies shrink and are hidden at this distance from the camera.", "group": "Rendering", "name": "DrawDistance" @@ -45029,7 +46029,107 @@ "advanced": true, "name": "PaintData" } - ] + ], + "propertiesFolderStructure": { + "folderName": "__ROOT", + "children": [ + { + "folderName": "Model", + "children": [ + { + "propertyName": "Model" + }, + { + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" + }, + { + "propertyName": "MinScale" + }, + { + "propertyName": "MaxScale" + }, + { + "propertyName": "RandomRotation" + }, + { + "propertyName": "RandomTint" + }, + { + "propertyName": "Size" + } + ] + }, + { + "folderName": "Placement", + "children": [ + { + "propertyName": "Density" + }, + { + "propertyName": "Seed" + }, + { + "propertyName": "Coverage" + }, + { + "propertyName": "Edits" + } + ] + }, + { + "folderName": "Ground", + "children": [ + { + "propertyName": "GroundLayer" + }, + { + "propertyName": "MaxSlope" + }, + { + "propertyName": "MinGroundZ" + }, + { + "propertyName": "MaxGroundZ" + } + ] + }, + { + "folderName": "Collisions", + "children": [ + { + "propertyName": "TrunkCollisions" + }, + { + "propertyName": "TrunkDiameter" + } + ] + }, + { + "folderName": "Rendering", + "children": [ + { + "propertyName": "DrawDistance" + }, + { + "propertyName": "CastShadow" + }, + { + "propertyName": "ReceiveShadow" + } + ] + }, + { + "propertyName": "PaintData" + } + ] + }, + "variants": [] } ] } diff --git a/examples/starting-first-person-farming/starting-first-person-farming.json b/examples/starting-first-person-farming/starting-first-person-farming.json index 6bd6a123f..70064b2dc 100644 --- a/examples/starting-first-person-farming/starting-first-person-farming.json +++ b/examples/starting-first-person-farming/starting-first-person-farming.json @@ -48372,7 +48372,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.2.0", + "version": "0.3.1", "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.", "", @@ -48408,6 +48408,12 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], @@ -51812,7 +51818,7 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], @@ -52055,18 +52061,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -52077,19 +52104,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -52118,17 +52148,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -52155,8 +52194,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -52250,11 +52292,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -52267,7 +52314,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -52295,14 +52343,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -52350,9 +52401,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -52371,6 +52427,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -52483,9 +52540,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -52540,12 +52597,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -52719,8 +52776,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -53017,11 +53077,246 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -53035,6 +53330,9 @@ }, { "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" } ] }, @@ -55423,6 +55721,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -55516,7 +55815,23 @@ "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -55909,7 +56224,7 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "expressionType": { @@ -55980,6 +56295,100 @@ } ], "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -56025,6 +56434,12 @@ }, { "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" } ] } @@ -56035,7 +56450,7 @@ "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" @@ -56044,12 +56459,58 @@ "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", "unit": "Pixel", "label": "Height of a copy", - "description": "Copies keep the proportions of the model.", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -56187,7 +56648,7 @@ "type": "Number", "unit": "Pixel", "label": "Trunk diameter", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, @@ -56242,7 +56703,13 @@ "propertyName": "Model" }, { - "propertyName": "Size" + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" }, { "propertyName": "MinScale" @@ -56255,6 +56722,9 @@ }, { "propertyName": "RandomTint" + }, + { + "propertyName": "Size" } ] }, diff --git a/examples/starting-first-person-horror/starting-first-person-horror.json b/examples/starting-first-person-horror/starting-first-person-horror.json index 9a13166a8..971ccca6e 100644 --- a/examples/starting-first-person-horror/starting-first-person-horror.json +++ b/examples/starting-first-person-horror/starting-first-person-horror.json @@ -30027,7 +30027,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.2.0", + "version": "0.3.1", "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.", "", @@ -30063,6 +30063,12 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], @@ -33467,7 +33473,7 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], @@ -33710,18 +33716,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -33732,19 +33759,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -33773,17 +33803,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -33810,8 +33849,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -33905,11 +33947,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -33922,7 +33969,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -33950,14 +33998,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -34005,9 +34056,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -34026,6 +34082,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -34138,9 +34195,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -34195,12 +34252,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -34374,8 +34431,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -34672,11 +34732,246 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -34690,6 +34985,9 @@ }, { "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" } ] }, @@ -37078,6 +37376,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -37171,7 +37470,23 @@ "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -37564,7 +37879,7 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "expressionType": { @@ -37635,6 +37950,100 @@ } ], "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -37680,6 +38089,12 @@ }, { "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" } ] } @@ -37690,7 +38105,7 @@ "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" @@ -37699,12 +38114,58 @@ "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", "unit": "Pixel", "label": "Height of a copy", - "description": "Copies keep the proportions of the model.", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -37842,7 +38303,7 @@ "type": "Number", "unit": "Pixel", "label": "Trunk diameter", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, @@ -37897,7 +38358,13 @@ "propertyName": "Model" }, { - "propertyName": "Size" + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" }, { "propertyName": "MinScale" @@ -37910,6 +38377,9 @@ }, { "propertyName": "RandomTint" + }, + { + "propertyName": "Size" } ] }, diff --git a/examples/starting-first-person-shooter-enemies/starting-first-person-shooter-enemies.json b/examples/starting-first-person-shooter-enemies/starting-first-person-shooter-enemies.json index 92af97702..3a32cb3fc 100644 --- a/examples/starting-first-person-shooter-enemies/starting-first-person-shooter-enemies.json +++ b/examples/starting-first-person-shooter-enemies/starting-first-person-shooter-enemies.json @@ -41266,7 +41266,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.2.0", + "version": "0.3.1", "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.", "", @@ -41302,6 +41302,12 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], @@ -44706,7 +44712,7 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], @@ -44949,18 +44955,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -44971,19 +44998,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -45012,17 +45042,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -45049,8 +45088,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -45144,11 +45186,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -45161,7 +45208,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -45189,14 +45237,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -45244,9 +45295,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -45265,6 +45321,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -45377,9 +45434,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -45434,12 +45491,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -45613,8 +45670,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -45911,11 +45971,246 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -45929,6 +46224,9 @@ }, { "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" } ] }, @@ -48317,6 +48615,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -48410,7 +48709,23 @@ "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -48803,7 +49118,7 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "expressionType": { @@ -48874,6 +49189,100 @@ } ], "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -48919,6 +49328,12 @@ }, { "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" } ] } @@ -48929,7 +49344,7 @@ "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" @@ -48938,12 +49353,58 @@ "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", "unit": "Pixel", "label": "Height of a copy", - "description": "Copies keep the proportions of the model.", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -49081,7 +49542,7 @@ "type": "Number", "unit": "Pixel", "label": "Trunk diameter", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, @@ -49136,7 +49597,13 @@ "propertyName": "Model" }, { - "propertyName": "Size" + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" }, { "propertyName": "MinScale" @@ -49149,6 +49616,9 @@ }, { "propertyName": "RandomTint" + }, + { + "propertyName": "Size" } ] }, diff --git a/examples/starting-first-person-shooter-horror/starting-first-person-shooter-horror.json b/examples/starting-first-person-shooter-horror/starting-first-person-shooter-horror.json index cf8167dec..e19a24b0e 100644 --- a/examples/starting-first-person-shooter-horror/starting-first-person-shooter-horror.json +++ b/examples/starting-first-person-shooter-horror/starting-first-person-shooter-horror.json @@ -46044,7 +46044,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.2.0", + "version": "0.3.1", "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.", "", @@ -46080,6 +46080,12 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], @@ -49484,7 +49490,7 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], @@ -49727,18 +49733,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -49749,19 +49776,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -49790,17 +49820,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -49827,8 +49866,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -49922,11 +49964,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -49939,7 +49986,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -49967,14 +50015,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -50022,9 +50073,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -50043,6 +50099,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -50155,9 +50212,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -50212,12 +50269,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -50391,8 +50448,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -50689,11 +50749,246 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -50707,6 +51002,9 @@ }, { "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" } ] }, @@ -53095,6 +53393,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -53188,7 +53487,23 @@ "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -53581,7 +53896,7 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "expressionType": { @@ -53652,6 +53967,100 @@ } ], "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -53697,6 +54106,12 @@ }, { "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" } ] } @@ -53707,7 +54122,7 @@ "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" @@ -53716,12 +54131,58 @@ "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", "unit": "Pixel", "label": "Height of a copy", - "description": "Copies keep the proportions of the model.", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -53859,7 +54320,7 @@ "type": "Number", "unit": "Pixel", "label": "Trunk diameter", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, @@ -53914,7 +54375,13 @@ "propertyName": "Model" }, { - "propertyName": "Size" + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" }, { "propertyName": "MinScale" @@ -53927,6 +54394,9 @@ }, { "propertyName": "RandomTint" + }, + { + "propertyName": "Size" } ] }, diff --git a/examples/starting-first-person-shooter/starting-first-person-shooter.json b/examples/starting-first-person-shooter/starting-first-person-shooter.json index 05a010415..daf429722 100644 --- a/examples/starting-first-person-shooter/starting-first-person-shooter.json +++ b/examples/starting-first-person-shooter/starting-first-person-shooter.json @@ -40053,7 +40053,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.2.0", + "version": "0.3.1", "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.", "", @@ -40089,6 +40089,12 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], @@ -43493,7 +43499,7 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], @@ -43736,18 +43742,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -43758,19 +43785,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -43799,17 +43829,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -43836,8 +43875,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -43931,11 +43973,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -43948,7 +43995,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -43976,14 +44024,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -44031,9 +44082,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -44052,6 +44108,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -44164,9 +44221,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -44221,12 +44278,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -44400,8 +44457,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -44698,11 +44758,246 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -44716,6 +45011,9 @@ }, { "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" } ] }, @@ -47104,6 +47402,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -47197,7 +47496,23 @@ "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -47590,7 +47905,7 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "expressionType": { @@ -47661,6 +47976,100 @@ } ], "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -47706,6 +48115,12 @@ }, { "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" } ] } @@ -47716,7 +48131,7 @@ "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" @@ -47725,12 +48140,58 @@ "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", "unit": "Pixel", "label": "Height of a copy", - "description": "Copies keep the proportions of the model.", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -47868,7 +48329,7 @@ "type": "Number", "unit": "Pixel", "label": "Trunk diameter", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, @@ -47923,7 +48384,13 @@ "propertyName": "Model" }, { - "propertyName": "Size" + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" }, { "propertyName": "MinScale" @@ -47936,6 +48403,9 @@ }, { "propertyName": "RandomTint" + }, + { + "propertyName": "Size" } ] }, diff --git a/examples/starting-first-person-survival-crafting/starting-first-person-survival-crafting.json b/examples/starting-first-person-survival-crafting/starting-first-person-survival-crafting.json index 503f431ed..fadc36e89 100644 --- a/examples/starting-first-person-survival-crafting/starting-first-person-survival-crafting.json +++ b/examples/starting-first-person-survival-crafting/starting-first-person-survival-crafting.json @@ -35562,7 +35562,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.2.0", + "version": "0.3.1", "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.", "", @@ -35598,6 +35598,12 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], @@ -39002,7 +39008,7 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], @@ -39245,18 +39251,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -39267,19 +39294,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -39308,17 +39338,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -39345,8 +39384,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -39440,11 +39482,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -39457,7 +39504,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -39485,14 +39533,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -39540,9 +39591,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -39561,6 +39617,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -39673,9 +39730,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -39730,12 +39787,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -39909,8 +39966,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -40207,11 +40267,246 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -40225,6 +40520,9 @@ }, { "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" } ] }, @@ -42613,6 +42911,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -42706,7 +43005,23 @@ "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -43099,7 +43414,7 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "expressionType": { @@ -43170,6 +43485,100 @@ } ], "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -43215,6 +43624,12 @@ }, { "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" } ] } @@ -43225,7 +43640,7 @@ "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" @@ -43234,12 +43649,58 @@ "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", "unit": "Pixel", "label": "Height of a copy", - "description": "Copies keep the proportions of the model.", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -43377,7 +43838,7 @@ "type": "Number", "unit": "Pixel", "label": "Trunk diameter", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, @@ -43432,7 +43893,13 @@ "propertyName": "Model" }, { - "propertyName": "Size" + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" }, { "propertyName": "MinScale" @@ -43445,6 +43912,9 @@ }, { "propertyName": "RandomTint" + }, + { + "propertyName": "Size" } ] }, diff --git a/examples/starting-first-person/starting-first-person.json b/examples/starting-first-person/starting-first-person.json index 82247f16d..3cc71369c 100644 --- a/examples/starting-first-person/starting-first-person.json +++ b/examples/starting-first-person/starting-first-person.json @@ -23187,7 +23187,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.2.0", + "version": "0.3.1", "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.", "", @@ -23223,6 +23223,12 @@ "cave" ], "authorIds": [], + "changelog": [ + { + "version": "0.3.1", + "breaking": "- Scattered models now put the origin of their 3D model on the ground (\"Point put on the ground\" property), instead of the center of its base. Copies of models whose origin is elsewhere, like some trees of the asset store, can move a bit. Set \"Point put on the ground\" to \"Center of the model base\" to keep their previous placement." + } + ], "dependencies": [], "globalVariables": [], "sceneVariables": [], @@ -26627,7 +26633,7 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], @@ -26870,18 +26876,39 @@ "};", "", "/**", + " * The parts of a model and its size, per unit of height, around the point", + " * of the model put on the ground (its anchor).", + " * @typedef {{", + " * parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>,", + " * width: number,", + " * depth: number,", + " * bottom: number,", + " * top: number,", + " * reach: number,", + " * }} ModelParts", + " * `bottom` and `top` are the Z of the model base and top from the anchor.", + " * `reach` is the farthest distance of the model from the anchor on X and Y,", + " * whatever the rotation of the copy.", + " */", + "", + "/** @type {ModelParts} */", + "const emptyModelParts = { parts: [], width: 0, depth: 0, bottom: 0, top: 1, reach: 0, sourceHeight: 1 };", + "", + "/**", " * The parts of a model, ready to be drawn with instancing: each glTF", - " * primitive with its material. Geometries are moved so that the model base", - " * is at the origin, its height is 1 and it stands along Z (like 3D model", - " * objects with a 90° rotation around X).", + " * primitive with its material. Geometries are moved so that the anchor is at", + " * the origin, the model height is 1 and it stands along Z (like 3D model", + " * objects with a 90° rotation around X, for models standing on Y).", " * @param {THREE.Object3D} modelScene", " * @param {object} uniforms Uniforms added to the materials.", - " * @returns {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}}", + " * @param {{isStandingOnZ: boolean, isAnchoredAtOrigin: boolean}} options", + " * The anchor is the origin of the model, or else the center of its base.", + " * @returns {ModelParts}", " */", - "const createModelParts = (modelScene, uniforms) => {", + "const createModelParts = (modelScene, uniforms, { isStandingOnZ, isAnchoredAtOrigin }) => {", " modelScene.updateMatrixWorld(true);", " const rootInverse = modelScene.matrixWorld.clone().invert();", - " const standUp = new THREE.Matrix4().makeRotationX(Math.PI / 2);", + " const standUp = isStandingOnZ ? new THREE.Matrix4() : new THREE.Matrix4().makeRotationX(Math.PI / 2);", " /** @type {Array<{mesh: THREE.Mesh, matrix: THREE.Matrix4}>} */", " const meshes = [];", " const box = new THREE.Box3();", @@ -26892,19 +26919,22 @@ " box.union(node.geometry.boundingBox.clone().applyMatrix4(matrix));", " meshes.push({ mesh: node, matrix });", " });", - " if (box.isEmpty()) return { parts: [], width: 0, depth: 0 };", + " if (box.isEmpty()) return emptyModelParts;", "", " const height = Math.max(box.max.z - box.min.z, 0.000001);", " // The Y axis of the scene goes down: the model is flipped, like 3D model objects.", " const normalization = new THREE.Matrix4()", " .makeScale(1 / height, -1 / height, 1 / height)", " .multiply(", - " new THREE.Matrix4().makeTranslation(", - " -(box.min.x + box.max.x) / 2,", - " -(box.min.y + box.max.y) / 2,", - " -box.min.z", - " )", + " isAnchoredAtOrigin", + " ? new THREE.Matrix4()", + " : new THREE.Matrix4().makeTranslation(", + " -(box.min.x + box.max.x) / 2,", + " -(box.min.y + box.max.y) / 2,", + " -box.min.z", + " )", " );", + " const normalizedBox = box.clone().applyMatrix4(normalization);", " const parts = meshes.map(({ mesh, matrix }) => {", " let geometry = mesh.geometry.clone();", " if (!geometry.index) {", @@ -26933,17 +26963,26 @@ " const material = Array.isArray(mesh.material) ? mesh.material.map(patchMaterial) : patchMaterial(mesh.material);", " return { geometry, material };", " });", + " // The sizes are relative to a height of 1, except sourceHeight which is in the model file units.", " return {", " parts,", + " sourceHeight: height,", " width: (box.max.x - box.min.x) / height,", " depth: (box.max.y - box.min.y) / height,", + " bottom: normalizedBox.min.z,", + " top: normalizedBox.max.z,", + " reach: Math.hypot(", + " Math.max(-normalizedBox.min.x, normalizedBox.max.x),", + " Math.max(-normalizedBox.min.y, normalizedBox.max.y)", + " ),", " };", "};", "", "/**", - " * A copy of the model: its base position in the scene, its scale (the", - " * height of the copy) and the key of its cell in the grid of copies.", - " * @typedef {{x: number, y: number, z: number, angle: number, height: number, key: number}} Copy", + " * A copy of the model: the position of its anchor in the scene (on the ground), its scale", + " * factor (1 = the model at its original size) and rendered height in pixels, and the key", + " * of its cell in the grid of copies.", + " * @typedef {{x: number, y: number, z: number, angle: number, height: number, scale: number, key: number}} Copy", " */", "", "/**", @@ -26970,8 +27009,11 @@ " scatterDrawDistance: { value: 1 },", " };", " /** @type {{parts: Array<{geometry: THREE.BufferGeometry, material: THREE.Material | THREE.Material[]}>, width: number, depth: number}} */", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " this.modelResourceName = '';", + " /** The properties the model was loaded from (see `_getModelKey`). */", + " this.modelKey = '';", " this.loadingModelResourceName = '';", " /**", " * Copies removed by events (for example, cut trees), by key.", @@ -27065,11 +27107,16 @@ " return this.paintData;", " }", "", - " updateFromProperties() {", + " _getModelKey() {", " const { object } = this;", - " const modelResourceName = object._getModel();", - " if (modelResourceName !== this.modelResourceName) {", - " this.modelResourceName = modelResourceName;", + " return object._getModel() + '|' + object._getModelUpAxis() + '|' + object._getModelAnchor();", + " }", + "", + " updateFromProperties() {", + " const modelKey = this._getModelKey();", + " if (modelKey !== this.modelKey) {", + " this.modelKey = modelKey;", + " this.modelResourceName = this.object._getModel();", " this._loadModel();", " }", " this.placement.placeAgain();", @@ -27082,7 +27129,8 @@ " material.dispose();", " }", " }", - " this.model = { parts: [], width: 0, depth: 0 };", + " /** @type {ModelParts} */", + " this.model = emptyModelParts;", " }", "", " _loadModel() {", @@ -27110,14 +27158,17 @@ " .catch(() => {});", " return;", " }", - " this.model = createModelParts(gltf.scene, this.uniforms);", + " this.model = createModelParts(gltf.scene, this.uniforms, {", + " isStandingOnZ: this.object._getModelUpAxis() === 'Z',", + " isAnchoredAtOrigin: this.object._getModelAnchor() === 'ModelOrigin',", + " });", " }", "", " _getPlacementKey() {", " const { object } = this;", " return [", " object.getX(), object.getY(), object.getZ(), object.getWidth(), object.getHeight(), object.getDepth(),", - " object._getDensity(), object._getSize(), object._getMinScale(), object._getMaxScale(),", + " object._getDensity(), object._getSize(), object._getScaleFactor(), object._getMinScale(), object._getMaxScale(),", " object._getRandomRotation(), object._getRandomTint(), object._getGroundLayer(), object._getMaxSlope(),", " object._getMinGroundZ(), object._getMaxGroundZ(), object._getSeed(), object._getDrawDistance(),", " object._getCastShadow(), object._getReceiveShadow(), this.model.parts.length,", @@ -27165,9 +27216,14 @@ " const { ground, z } = findHighestGround(grounds, sceneX, sceneY, this._groundAtCopy);", " const groundZ = ground ? z : object.getZ();", " if (groundZ < object._getMinGroundZ() || groundZ > object._getMaxGroundZ()) return null;", - " const copyHeight =", - " Math.max(object._getSize(), 0) *", - " (object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor);", + " const randomScale = object._getMinScale() + (object._getMaxScale() - object._getMinScale()) * scaleFactor;", + " const sourceHeightInPixels = this.model.sourceHeight * 100;", + " // Objects made before \"Scale factor of a copy\" (store prefabs included) scale their fixed", + " // height (deprecated \"Size\" property) instead of the model height: they look the same at 100%.", + " const legacyHeight = object._getSize();", + " const baseHeight = legacyHeight > 0 ? legacyHeight : sourceHeightInPixels;", + " const copyHeight = (Math.max(object._getScaleFactor(), 0) / 100) * randomScale * baseHeight;", + " const copyScale = copyHeight / sourceHeightInPixels;", " if (ground) {", " if (ground.getSlopeAt(sceneX, sceneY) > object._getMaxSlope()) return null;", " const groundLayer = parseInt(object._getGroundLayer(), 10) || 0;", @@ -27186,6 +27242,7 @@ " z: groundZ,", " angle: object._getRandomRotation() ? angle : 0,", " height: copyHeight,", + " scale: copyScale,", " key,", " };", " }", @@ -27298,9 +27355,9 @@ " this._color.setRGB(1, 1, 1).lerp(this._tint, createRandom(copy.key + 1)());", " this._color.toArray(colors, index * 3);", " }", - " const reach = (copy.height * Math.max(this.model.width, this.model.depth)) / 2;", - " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z));", - " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height));", + " const reach = copy.height * this.model.reach;", + " min.min(this._chunkPosition.set(copy.x - reach, copy.y - reach, copy.z + copy.height * this.model.bottom));", + " max.max(this._chunkPosition.set(copy.x + reach, copy.y + reach, copy.z + copy.height * this.model.top));", " });", " const instanceMatrix = new THREE.InstancedBufferAttribute(matrices, 16);", " const instanceColor = colors ? new THREE.InstancedBufferAttribute(colors, 3) : null;", @@ -27355,12 +27412,12 @@ " ray.origin.multiply(scale).add(this._chunkPosition.set(object.getX(), object.getY(), object.getZ()));", " ray.direction.multiply(scale);", "", - " const reachPerHeight = Math.max(this.model.width, this.model.depth) / 2;", + " const { reach: reachPerHeight, bottom, top } = this.model;", " const instanceMatrices = chunk.meshes[0].instanceMatrix.array;", " chunk.copies.forEach((copy, index) => {", " const reach = copy.height * reachPerHeight;", - " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z);", - " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height);", + " this._raycastBox.min.set(copy.x - reach, copy.y - reach, copy.z + copy.height * bottom);", + " this._raycastBox.max.set(copy.x + reach, copy.y + reach, copy.z + copy.height * top);", " if (!ray.intersectsBox(this._raycastBox)) return;", " this._raycastMesh.matrixWorld.fromArray(instanceMatrices, index * 16).premultiply(group.matrixWorld);", " for (const mesh of chunk.meshes) {", @@ -27534,8 +27591,11 @@ " const depth = object.getDepth() || 1;", " let indexOffset = 0;", " copies.forEach((copy, copyIndex) => {", - " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * (copy.height / Math.max(object._getSize(), 1));", - " const top = copy.z + copy.height * 0.8;", + " const legacyHeight = object._getSize();", + " const trunkScale = legacyHeight > 0 ? copy.height / legacyHeight : copy.scale;", + " const radius = (Math.max(object._getTrunkDiameter(), 0) / 2) * trunkScale;", + " // Up to 80% of the model height, at least a bit above the ground.", + " const top = copy.z + copy.height * Math.max(this.model.bottom + 0.8, 0.1);", " const firstVertex = copyIndex * verticesPerCopy;", " const setVertex = (vertex, x, y, z) => {", " positions[vertex * 3] = (x - object.getX()) / width;", @@ -27832,11 +27892,246 @@ ], "parameterObjects": "", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "parameters": [], "objectGroups": [] + }, + { + "description": "Create an object at the position, angle and scale of the copy of scattered models nearest to a position, and optionally erase that copy. The created object stays picked, so you apply actions to it right after.", + "fullName": "Replace the nearest copy with an object", + "functionType": "Action", + "name": "ReplaceNearestCopyWithObject", + "sentence": "Replace the copy of _PARAM1_ nearest to _PARAM5_; _PARAM6_ with _PARAM2_ (erase the copy: _PARAM7_)", + "events": [ + { + "aiGeneratedEventId": "01M6WAPE2W8KS8P2871Y641HST", + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyX", + "=", + "ScatterObject.NearestCopyX(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyY", + "=", + "ScatterObject.NearestCopyY(X, Y)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyZ", + "=", + "ScatterObject.NearestCopyZ(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyAngle", + "=", + "ScatterObject.NearestCopyAngle(CopyX, CopyY)" + ] + }, + { + "type": { + "value": "SetNumberVariable" + }, + "parameters": [ + "CopyScale", + "=", + "ScatterObject.NearestCopyScale(CopyX, CopyY)" + ] + } + ], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "NumberVariable" + }, + "parameters": [ + "CopyScale", + ">", + "0" + ] + } + ], + "actions": [], + "events": [ + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [ + { + "type": { + "value": "BooleanVariable" + }, + "parameters": [ + "EraseTheCopy", + "True", + "" + ] + } + ], + "actions": [ + { + "type": { + "value": "Terrain3D::ScatteredModels::EraseCopies" + }, + "parameters": [ + "ScatterObject", + "CopyX", + "CopyY", + "1", + "" + ] + } + ] + }, + { + "type": "BuiltinCommonInstructions::Standard", + "conditions": [], + "actions": [ + { + "type": { + "value": "Create" + }, + "parameters": [ + "", + "ObjectToCreate", + "CopyX", + "CopyY", + "ScatterObject.Layer()" + ] + }, + { + "type": { + "value": "Scene3D::Base3DBehavior::SetZ" + }, + "parameters": [ + "ObjectToCreate", + "Object3DBehavior", + "=", + "CopyZ" + ] + }, + { + "type": { + "value": "SetAngle" + }, + "parameters": [ + "ObjectToCreate", + "=", + "CopyAngle" + ] + }, + { + "type": { + "value": "ScalableCapability::ScalableBehavior::SetValue" + }, + "parameters": [ + "ObjectToCreate", + "ScaleBehavior", + "=", + "CopyScale" + ] + } + ] + } + ] + } + ], + "variables": [ + { + "name": "CopyX", + "type": "number", + "value": 0 + }, + { + "name": "CopyY", + "type": "number", + "value": 0 + }, + { + "name": "CopyZ", + "type": "number", + "value": 0 + }, + { + "name": "CopyAngle", + "type": "number", + "value": 0 + }, + { + "name": "CopyScale", + "type": "number", + "value": 0 + } + ] + } + ], + "parameters": [ + { + "description": "Scattered models", + "name": "ScatterObject", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "objectList" + }, + { + "description": "Object to create", + "name": "ObjectToCreate", + "type": "objectListOrEmptyIfJustDeclared" + }, + { + "description": "3D behavior", + "name": "Object3DBehavior", + "supplementaryInformation": "Scene3D::Base3DBehavior", + "type": "behavior" + }, + { + "description": "Scale behavior", + "name": "ScaleBehavior", + "supplementaryInformation": "ScalableCapability::ScalableBehavior", + "type": "behavior" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + }, + { + "defaultValue": "yes", + "description": "Erase the copy", + "name": "EraseTheCopy", + "optional": true, + "type": "yesorno" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -27850,6 +28145,9 @@ }, { "functionName": "DefineScatterHelperClasses" + }, + { + "functionName": "ReplaceNearestCopyWithObject" } ] }, @@ -30238,6 +30536,7 @@ { "assetStoreId": "", "name": "AreaPlaceholder", + "persistentUuid": "06ba5ea9-2e29-4867-90ba-dd3268867171", "type": "Scene3D::Cube3DObject", "variables": [], "effects": [], @@ -30331,7 +30630,23 @@ "initialVariables": [] } ], - "editionSettings": {}, + "editionSettings": { + "grid": false, + "gridType": "rectangular", + "gridWidth": 32, + "gridHeight": 32, + "gridDepth": 32, + "gridOffsetX": 0, + "gridOffsetY": 0, + "gridOffsetZ": 0, + "gridColor": 10401023, + "gridAlpha": 0.8, + "snap": false, + "zoomFactor": 0.35, + "windowMask": false, + "selectedLayer": "", + "gameEditorMode": "instances-editor" + }, "eventsFunctions": [ { "fullName": "", @@ -30724,7 +31039,7 @@ ], "parameterObjects": "Object", "useStrict": true, - "eventsSheetExpanded": false + "eventsSheetExpanded": true } ], "expressionType": { @@ -30795,6 +31110,100 @@ } ], "objectGroups": [] + }, + { + "description": "The angle (in degrees, around the Z axis) of the copy nearest to a position (0 when there is no copy).", + "fullName": "Angle of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyAngle", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W13A1GCBRR93J2V6ZPRBYZ", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? (copy.angle * 180) / Math.PI : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] + }, + { + "description": "The scale factor of the copy nearest to a position (1 = the model at its original size, 0 when there is no copy).", + "fullName": "Scale of the nearest copy", + "functionType": "Expression", + "group": "Copies", + "name": "NearestCopyScale", + "sentence": "", + "events": [ + { + "aiGeneratedEventId": "01M6W15Y2Y5KXQHWMS0ZX3RSMT", + "type": "BuiltinCommonInstructions::JsCode", + "inlineCode": [ + "/** @type {gdjs.CustomRuntimeObject3D} */", + "const object = objects[0];", + "const x = eventsFunctionContext.getArgument(\"X\");", + "const y = eventsFunctionContext.getArgument(\"Y\");", + "const copy = object.__terrainScatter.findNearestCopy(x, y);", + "eventsFunctionContext.returnValue = copy ? copy.scale : 0;" + ], + "parameterObjects": "Object", + "useStrict": true, + "eventsSheetExpanded": false + } + ], + "expressionType": { + "type": "expression" + }, + "parameters": [ + { + "description": "Object", + "name": "Object", + "supplementaryInformation": "Terrain3D::ScatteredModels", + "type": "object" + }, + { + "description": "X position", + "name": "X", + "type": "expression" + }, + { + "description": "Y position", + "name": "Y", + "type": "expression" + } + ], + "objectGroups": [] } ], "eventsFunctionsFolderStructure": { @@ -30840,6 +31249,12 @@ }, { "functionName": "NearestCopyDistance" + }, + { + "functionName": "NearestCopyAngle" + }, + { + "functionName": "NearestCopyScale" } ] } @@ -30850,7 +31265,7 @@ "value": "", "type": "Resource", "label": "3D model", - "description": "The model copied everywhere (a .glb file). Models standing on the Y axis, like most models of the asset store, are stood up along Z.", + "description": "The model copied everywhere (a .glb file).", "group": "Model", "extraInformation": [ "model3D" @@ -30859,12 +31274,58 @@ "name": "Model" }, { - "value": "300", + "value": "Y", + "type": "Choice", + "label": "Model up axis", + "description": "The axis the model stands on in the file. Most models of the asset store stand on Y, they are stood up along Z.", + "group": "Model", + "choices": [ + { + "label": "Y (like most models)", + "value": "Y" + }, + { + "label": "Z (like the scene)", + "value": "Z" + } + ], + "name": "ModelUpAxis" + }, + { + "value": "ModelOrigin", + "type": "Choice", + "label": "Point put on the ground", + "description": "The point of the model put on the ground, also at the center of the trunk collision. Use the origin of the model when the trunk is not at the center of the model (asymmetric canopy, leaning tree), or to sink the base of copies in the ground on slopes.", + "group": "Model", + "choices": [ + { + "label": "Center of the model base", + "value": "BaseCenter" + }, + { + "label": "Origin of the model", + "value": "ModelOrigin" + } + ], + "name": "ModelAnchor" + }, + { + "value": "100", + "type": "Number", + "unit": "Dimensionless", + "label": "Scale factor of a copy", + "description": "The size of a copy, in percent of the original model size (100 = the model at its original size, like a 3D model object with a scale of 1). For objects made before this property, like the ones of the asset store, it is a percent of their former height of a copy. Copies keep the proportions of the model.", + "group": "Model", + "name": "ScaleFactor" + }, + { + "value": "0", "type": "Number", "unit": "Pixel", "label": "Height of a copy", - "description": "Copies keep the proportions of the model.", + "description": "Kept for objects made before \"Scale factor of a copy\": when greater than 0, copies have this height, multiplied by the scale factor.", "group": "Model", + "deprecated": true, "name": "Size" }, { @@ -31002,7 +31463,7 @@ "type": "Number", "unit": "Pixel", "label": "Trunk diameter", - "description": "The diameter of the collision shape of a copy at the height given in \"Height of a copy\" (it is scaled with the copy).", + "description": "The diameter of the collision shape of a copy at the scale given in \"Scale factor of a copy\" (it is scaled with the copy).", "group": "Collisions", "name": "TrunkDiameter" }, @@ -31057,7 +31518,13 @@ "propertyName": "Model" }, { - "propertyName": "Size" + "propertyName": "ModelUpAxis" + }, + { + "propertyName": "ModelAnchor" + }, + { + "propertyName": "ScaleFactor" }, { "propertyName": "MinScale" @@ -31070,6 +31537,9 @@ }, { "propertyName": "RandomTint" + }, + { + "propertyName": "Size" } ] }, diff --git a/rendering-screenshots/3d-island-terrain/the-island-from-above.png b/rendering-screenshots/3d-island-terrain/the-island-from-above.png index 43ae832c49c3d67b6eebad7bcb05ad1e6fde508d..b8cf2fda021490c4625a5d911893f570a92b696d 100644 GIT binary patch literal 575246 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°@;+dx0 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%0D()p4}*6m*)f-RFiIx*>1 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