Skip to content

Commit 84c9517

Browse files
committed
Load terrain heightmap images once they are loaded, and show Edits in advanced properties
- When the heightmap image is not loaded yet (for example just chosen in the scene editor), the relief is used and the image is checked again about every half second, instead of never. - The Edits property is with the sculpt data, in the advanced properties. - A gameplay test changes the relief like the scene editor, paints with the editor tools and reloads the saved data. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0156q1ijXegCexbGspqriikR
1 parent c05e160 commit 84c9517

5 files changed

Lines changed: 131 additions & 10 deletions

File tree

‎extensions/community/Terrain3D.json‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,18 @@
514514
"};",
515515
"",
516516
"/**",
517+
" * @param {gdjs.RuntimeGame} game",
518+
" * @param {string} imageResourceName",
519+
" * @returns {CanvasImageSource | null} The image, or null if it's not loaded (yet).",
520+
" */",
521+
"const getLoadedImage = (game, imageResourceName) => {",
522+
" const texture = game.getImageManager().getPIXITexture(imageResourceName);",
523+
" const source = texture && texture.baseTexture && texture.baseTexture.getDrawableSource();",
524+
" // An 8 x 8 placeholder is given for images that are not loaded.",
525+
" return source && source.width > 8 && source.height > 8 ? source : null;",
526+
"};",
527+
"",
528+
"/**",
517529
" * Loads heights from an image: black is the lowest, white the highest.",
518530
" * Heights have 16 bits of precision when the red channel holds the most",
519531
" * significant byte and the green one the least significant (a gray image",
@@ -524,10 +536,8 @@
524536
" * @returns {boolean} true if the image could be read.",
525537
" */",
526538
"const loadHeightsFromImage = (data, game, imageResourceName) => {",
527-
" const texture = game.getImageManager().getPIXITexture(imageResourceName);",
528-
" const source = texture && texture.baseTexture && texture.baseTexture.getDrawableSource();",
529-
" // An 8 x 8 placeholder is given for images that are not loaded.",
530-
" if (!source || source.width <= 8 || source.height <= 8) return false;",
539+
" const source = getLoadedImage(game, imageResourceName);",
540+
" if (!source) return false;",
531541
"",
532542
" const canvas = document.createElement('canvas');",
533543
" canvas.width = source.width;",
@@ -1522,6 +1532,9 @@
15221532
" this._strokeChangedSamples = new SampleRectangle();",
15231533
" /** The properties the data was loaded from, to reload it only if they change. */",
15241534
" this.loadedFrom = '';",
1535+
" /** True when the heightmap image was not loaded yet: the relief is used until it is. */",
1536+
" this.isWaitingForHeightmap = false;",
1537+
" this._framesWaitingForHeightmap = 0;",
15251538
" /** The last value of the sculpt data property read or saved. */",
15261539
" this.sculptData = '';",
15271540
" /**",
@@ -1566,6 +1579,7 @@
15661579
" const validResolution = RESOLUTIONS.indexOf(resolution) !== -1 ? resolution : DEFAULT_RESOLUTION;",
15671580
" this.sculptData = object._getSculptData();",
15681581
" this.loadedFrom = this._getSourceProperties();",
1582+
" this.isWaitingForHeightmap = false;",
15691583
"",
15701584
" this.baseData = this._getBaseData(validResolution);",
15711585
" let data = codec.decode(this.sculptData, (resolution) => copyTerrainData(this._getBaseData(resolution)));",
@@ -1591,6 +1605,8 @@
15911605
" !!heightmapImage && loadHeightsFromImage(data, object.getRuntimeScene().getGame(), heightmapImage);",
15921606
" if (!isHeightmapLoaded) {",
15931607
" relief.generate(data, object._getRelief(), object._getSeed());",
1608+
" // The image can still be loading: it's checked again later (see `update`).",
1609+
" if (heightmapImage) this.isWaitingForHeightmap = true;",
15941610
" }",
15951611
" edits.apply(data, object._getEdits());",
15961612
" // Not kept if the image is missing: it may be loaded later.",
@@ -1871,6 +1887,10 @@
18711887
" }",
18721888
"",
18731889
" update() {",
1890+
" if (this.isWaitingForHeightmap && ++this._framesWaitingForHeightmap % 30 === 0) {",
1891+
" const game = this.object.getRuntimeScene().getGame();",
1892+
" if (getLoadedImage(game, this.object._getHeightmapImage())) this.loadFromProperties();",
1893+
" }",
18741894
" const transform = this._getTransform();",
18751895
" if (transform !== this._transform) {",
18761896
" this._transform = transform;",
@@ -3448,6 +3468,7 @@
34483468
"label": "Edits",
34493469
"description": "Changes of the relief or heightmap, as a JSON list, for example [{\"tool\": \"raise\", \"x\": 0.3, \"y\": 0.4, \"radius\": 0.15, \"height\": 0.4}, {\"tool\": \"paint\", \"x\": 0.1, \"y\": 0.9, \"toX\": 0.8, \"toY\": 0.2, \"radius\": 0.02, \"layer\": 2}]. Tools: raise (height, negative to lower), flatten (z), smooth (strength) and paint (layer, from 1 to 4, and strength). x, y and radius are fractions (0 to 1) of the terrain size, height and z fractions of its depth. Add toX and toY to change the ground along a line (paths, rivers, ridges). Shown in the scene editor.",
34503470
"group": "Shape",
3471+
"advanced": true,
34513472
"name": "Edits"
34523473
},
34533474
{

‎sources/3d-world/Terrain3D/build.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ def args(*names):
204204
description="Each seed gives a different terrain with the same relief."),
205205
prop("HeightmapImage", "Resource", "", "Heightmap image", group="Shape", extra=["image"],
206206
description="Optional. Replaces the relief: black is the lowest, white the highest (the object depth)."),
207-
prop("Edits", "MultilineString", "", "Edits", group="Shape",
207+
prop("Edits", "MultilineString", "", "Edits", group="Shape", advanced=True,
208208
description="Changes of the relief or heightmap, as a JSON list, for example "
209209
'[{"tool": "raise", "x": 0.3, "y": 0.4, "radius": 0.15, "height": 0.4}, '
210210
'{"tool": "paint", "x": 0.1, "y": 0.9, "toX": 0.8, "toY": 0.2, "radius": 0.02, "layer": 2}]. '

‎sources/3d-world/Terrain3D/helper.js‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,18 @@ const edits = {
459459
},
460460
};
461461

462+
/**
463+
* @param {gdjs.RuntimeGame} game
464+
* @param {string} imageResourceName
465+
* @returns {CanvasImageSource | null} The image, or null if it's not loaded (yet).
466+
*/
467+
const getLoadedImage = (game, imageResourceName) => {
468+
const texture = game.getImageManager().getPIXITexture(imageResourceName);
469+
const source = texture && texture.baseTexture && texture.baseTexture.getDrawableSource();
470+
// An 8 x 8 placeholder is given for images that are not loaded.
471+
return source && source.width > 8 && source.height > 8 ? source : null;
472+
};
473+
462474
/**
463475
* Loads heights from an image: black is the lowest, white the highest.
464476
* Heights have 16 bits of precision when the red channel holds the most
@@ -470,10 +482,8 @@ const edits = {
470482
* @returns {boolean} true if the image could be read.
471483
*/
472484
const loadHeightsFromImage = (data, game, imageResourceName) => {
473-
const texture = game.getImageManager().getPIXITexture(imageResourceName);
474-
const source = texture && texture.baseTexture && texture.baseTexture.getDrawableSource();
475-
// An 8 x 8 placeholder is given for images that are not loaded.
476-
if (!source || source.width <= 8 || source.height <= 8) return false;
485+
const source = getLoadedImage(game, imageResourceName);
486+
if (!source) return false;
477487

478488
const canvas = document.createElement('canvas');
479489
canvas.width = source.width;
@@ -1468,6 +1478,9 @@ class Terrain {
14681478
this._strokeChangedSamples = new SampleRectangle();
14691479
/** The properties the data was loaded from, to reload it only if they change. */
14701480
this.loadedFrom = '';
1481+
/** True when the heightmap image was not loaded yet: the relief is used until it is. */
1482+
this.isWaitingForHeightmap = false;
1483+
this._framesWaitingForHeightmap = 0;
14711484
/** The last value of the sculpt data property read or saved. */
14721485
this.sculptData = '';
14731486
/**
@@ -1512,6 +1525,7 @@ class Terrain {
15121525
const validResolution = RESOLUTIONS.indexOf(resolution) !== -1 ? resolution : DEFAULT_RESOLUTION;
15131526
this.sculptData = object._getSculptData();
15141527
this.loadedFrom = this._getSourceProperties();
1528+
this.isWaitingForHeightmap = false;
15151529

15161530
this.baseData = this._getBaseData(validResolution);
15171531
let data = codec.decode(this.sculptData, (resolution) => copyTerrainData(this._getBaseData(resolution)));
@@ -1537,6 +1551,8 @@ class Terrain {
15371551
!!heightmapImage && loadHeightsFromImage(data, object.getRuntimeScene().getGame(), heightmapImage);
15381552
if (!isHeightmapLoaded) {
15391553
relief.generate(data, object._getRelief(), object._getSeed());
1554+
// The image can still be loading: it's checked again later (see `update`).
1555+
if (heightmapImage) this.isWaitingForHeightmap = true;
15401556
}
15411557
edits.apply(data, object._getEdits());
15421558
// Not kept if the image is missing: it may be loaded later.
@@ -1817,6 +1833,10 @@ class Terrain {
18171833
}
18181834

18191835
update() {
1836+
if (this.isWaitingForHeightmap && ++this._framesWaitingForHeightmap % 30 === 0) {
1837+
const game = this.object.getRuntimeScene().getGame();
1838+
if (getLoadedImage(game, this.object._getHeightmapImage())) this.loadFromProperties();
1839+
}
18201840
const transform = this._getTransform();
18211841
if (transform !== this._transform) {
18221842
this._transform = transform;

‎sources/3d-world/gameplay-tests/02-raise.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ terrain.raise(hillX, hillY, hillX, hillY, 300, 150);
1616
const hillZ = terrain.getHeightAt(hillX, hillY);
1717
harness.assert(Math.abs(hillZ - groundBefore - 150) < 1, 'The ground is raised by 150 at the center.');
1818
await harness.stepFrames(2);
19-
harness.setObjectPosition(start.id, hillX - 15, hillY - 15, hillZ + 80);
19+
harness.setObjectPosition(start.id, hillX - 15, hillY - 15, hillZ + 40);
2020
await harness.stepUntil(() => getPlayer().behaviors.PhysicsCharacter3D.state.IsOnFloor === false, { maxFrames: 5 });
2121
const landed = await harness.stepUntil(
2222
() => getPlayer().behaviors.PhysicsCharacter3D.state.IsOnFloor === true,
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Painting after the relief was changed in the scene editor
2+
await harness.goToScene('Game Scene');
3+
// Faster in the software renderer used to run tests.
4+
harness.setGameResolutionSize(160, 90);
5+
await harness.stepFrames(2);
6+
const game = harness.getRuntimeGame();
7+
const scene = harness.getCurrentRuntimeScene();
8+
scene.getObjects('Player')[0].getBehavior('ThirdPersonCamera').activate(false);
9+
scene.getObjects('Grass')[0].hide(true);
10+
const terrainObject = scene.getObjects('Terrain')[0];
11+
const terrain = terrainObject.__terrain3D;
12+
const targetX = 640, targetY = 1100;
13+
14+
// The scene editor changes the relief like this (hot-reload of the object).
15+
const oldObjectData = scene._objects.get('Terrain');
16+
const newObjectData = JSON.parse(JSON.stringify(oldObjectData));
17+
newObjectData.content.Relief = 'Mountains';
18+
const heightBefore = terrain.getHeightAt(targetX, targetY);
19+
terrainObject.updateFromObjectData(oldObjectData, newObjectData);
20+
harness.assert(terrainObject._getRelief() === 'Mountains', 'The relief is changed.');
21+
harness.assert(Math.abs(terrain.getHeightAt(targetX, targetY) - heightBefore) > 1, 'The ground is changed right away.');
22+
23+
gdjs.evtTools.camera.setCameraX(scene, targetX, '', 0);
24+
gdjs.evtTools.camera.setCameraY(scene, targetY, '', 0);
25+
gdjs.scene3d.camera.setCameraZ(scene, terrain.getHeightAt(targetX, targetY) + 800, '', 0);
26+
gdjs.scene3d.camera.setCameraRotationX(scene, 0, '', 0);
27+
await harness.stepFrames(1);
28+
29+
const savedProperties = [];
30+
let toolbarItems = null;
31+
const editor = {
32+
getRuntimeGame: () => game,
33+
getSelectedObjects: () => [terrainObject],
34+
captureLeftMouseButton: () => {},
35+
updateObjectProperties: (objectName, properties) => savedProperties.push(properties),
36+
showToolbar: (toolbarId, items) => (toolbarItems = items),
37+
};
38+
const updateTools = () => gdjs.callbacksInGameEditorPostStep.forEach((callback) => callback(editor));
39+
updateTools();
40+
toolbarItems.find((item) => item.id === 'Paint2').onClick();
41+
updateTools();
42+
const layerWeight = () => terrain.getLayerWeightAt(targetX, targetY, 2);
43+
const weightBefore = layerWeight();
44+
harness.setMousePositionScreen(game.getGameResolutionWidth() / 2, game.getGameResolutionHeight() / 2);
45+
harness.setMouseButtonPressed(true);
46+
for (let frame = 0; frame < 20 && layerWeight() < 0.8; frame++) {
47+
await harness.stepFrames(1);
48+
updateTools();
49+
await new Promise((resolve) => setTimeout(resolve, 30));
50+
}
51+
harness.setMouseButtonPressed(false);
52+
await harness.stepFrames(1, { onFrame: updateTools });
53+
const weightAfter = layerWeight();
54+
console.log('layer 2 weight before=' + weightBefore.toFixed(2) + ' after=' + weightAfter.toFixed(2) + ' saves=' + savedProperties.length);
55+
harness.assert(weightAfter > weightBefore + 0.3, 'The layer is painted on the mountains.');
56+
harness.assert(savedProperties.length === 1, 'The stroke is saved.');
57+
58+
// The saved data gives back the painted mountains.
59+
terrainObject._objectData.SculptData = savedProperties[0].SculptData;
60+
terrain.loadFromProperties();
61+
harness.assert(Math.abs(layerWeight() - weightAfter) < 0.02, 'The saved data gives back the paint on the mountains.');
62+
63+
// A heightmap image still loading: the relief is used until the image is there.
64+
const imageManager = game.getImageManager();
65+
const getPIXITexture = imageManager.getPIXITexture;
66+
imageManager.getPIXITexture = () => null;
67+
const objectDataWithMountains = scene._objects.get('Terrain');
68+
const objectDataWithHeightmap = JSON.parse(JSON.stringify(objectDataWithMountains));
69+
objectDataWithHeightmap.content.HeightmapImage = 'assets/TerrainRock.jpg';
70+
objectDataWithHeightmap.content.SculptData = '';
71+
const mountainsHeight = terrain.getHeightAt(targetX, targetY);
72+
try {
73+
terrainObject.updateFromObjectData(objectDataWithMountains, objectDataWithHeightmap);
74+
} finally {
75+
imageManager.getPIXITexture = getPIXITexture;
76+
}
77+
harness.assert(terrain.isWaitingForHeightmap, 'The terrain waits for the heightmap image.');
78+
await harness.stepFrames(31);
79+
harness.assert(!terrain.isWaitingForHeightmap, 'The heightmap image is used once loaded.');
80+
harness.assert(Math.abs(terrain.getHeightAt(targetX, targetY) - mountainsHeight) > 1, 'The ground follows the heightmap.');

0 commit comments

Comments
 (0)