How to apply texture on 3d height map

Hi does anyone know how to add textures on 3d height map extension.


sorry bag example but
This is an example of a height map that I created using that extension and the problem is. How do I add a texture to it? Does anyone know how?

I’m very late to this but I managed to this for my own project. Are you still active and need help?

Hi, thank you for answering my problem, even though I no longer continue using the 3D height map extension, I am curious about how you did it.

So for it to work you have to modify the extension it self but its easy. Since I don’t know Javascript I used Claude to modify the DefineHelperClasses function’s Javascript code.
Képernyőkép 2025-11-16 132141


You need to replace the whole Javascript code with this instead:

if (gdjs.__heightMap3DExtension) {
    return;
}

const defaultWidth = 64;
const defaultHeight = 64;
const defaultDepth = 64;

class HeightMap {
    /** @type {gdjs.CustomRuntimeObject3D} */
    object;
    /** @type {THREE.Mesh} */
    mesh;

    /** @type {THREE.TypedArray} */
    vertices;

    /** @type {integer} */
    dimX;
    /** @type {integer} */
    dimY;

    /** @type {string} */
    texturePath;
    /** @type {THREE.Texture} */
    texture;

    hasChangedThisFrame = false;
    isDirty = false;

    /**
     * @param object {gdjs.CustomRuntimeObject}
     * @param dimX {integer}
     * @param dimY {integer}
     * @param texturePath {string}
     * @param wireframe {boolean}
     */
    constructor(object, dimX, dimY, texturePath, wireframe) {
        this.object = object;
        this.dimX = dimX;
        this.dimY = dimY;
        this.texturePath = texturePath;

        this.rebuildPlane();
        this.loadTexture(texturePath);
        this.setWireframe(wireframe);
    }

    /**
     * @param dimX {integer}
     * @param dimY {integer}
     */
    clearAndResize(dimX, dimY) {
        this.dimX = dimX;
        this.dimY = dimY;
        this.rebuildPlane();
    }

    rebuildPlane() {
        const material = this.mesh ? this.mesh.material : new THREE.MeshStandardMaterial({
            roughness: 0.9,
            metalness: 0.0,
            envMapIntensity: 0.5
        });
        if (this.mesh) {
            this.mesh.removeFromParent();
        }
        const geometry = new THREE.PlaneGeometry(64, 64, this.dimX - 1, this.dimY - 1);
        this.vertices = geometry.attributes.position.array;
        this.mesh = new THREE.Mesh(geometry, material);
        this.mesh.rotation.order = 'ZYX';
        this.mesh.position.x = defaultWidth / 2;
        this.mesh.position.y = defaultHeight / 2;
        this.mesh.scale.y = -1;
        this.object.get3DRendererObject().add(this.mesh);
        this.hasChangedThisFrame = true;
    }

    forceUpdate() {
        if (this.isDirty) {
            this.isDirty = false;
            this.mesh.geometry.attributes.position.needsUpdate = true;
            this.mesh.geometry.computeVertexNormals();
        }
    }

    updateIfNeeded() {
        if (this.isDirty && !this.object.isHidden()) {
            this.isDirty = false;
            this.mesh.geometry.attributes.position.needsUpdate = true;
            this.mesh.geometry.computeVertexNormals();
        }
        this.hasChangedThisFrame = false;
    }

    setAsDirty() {
        this.hasChangedThisFrame = true;
        this.isDirty = true;
    }

    /**
     * @param indexX {integer}
     * @param indexY {integer}
     * @param value {float} a value between 0 and 1
     */
    setGridValue(indexX, indexY, value) {
        this.vertices[this.getVerticeZIndex(indexX, indexY)] =
            defaultDepth * gdjs.evtTools.common.clamp(0, 1, value);
        this.setAsDirty();
    }

    /**
     * @param indexX {integer}
     * @param indexY {integer}
     * @returns {float} a value between 0 and 1
     */
    getGridValue(indexX, indexY) {
        return this.vertices[this.getVerticeZIndex(indexX, indexY)] / defaultDepth;
    }

    /**
     * @param indexX {integer}
     * @param indexY {integer}
     * @returns {integer}
     */
    getVerticeZIndex(indexX, indexY) {
        return 2 + 3 * (indexX + indexY * this.dimX);
    }

    /**
     * @param pointX {float} position in the scene
     * @param pointY {float} position in the scene
     * @return {float} the Z coordinate of the field point
     */
    getFieldZ(pointX, pointY) {
        const objectX = this.object.getX();
        const objectY = this.object.getY();
        const objectWidth = this.object.getWidth();
        const objectHeight = this.object.getHeight();
        if (pointX < objectX || pointX > objectX + objectWidth ||
            pointY < objectY || pointY > objectY + objectHeight) {
            return 0;
        }
        const x = (this.dimX - 1) * (pointX - objectX) / objectWidth;
        const y = (this.dimY - 1) * (pointY - objectY) / objectHeight;
        const integerPartX = Math.floor(x);
        const integerPartY = Math.floor(y);
        const floatPartX = x - integerPartX;
        const floatPartY = y - integerPartY;
        const bottomLeft = this.getGridValue(integerPartX, integerPartY + 1);
        const topRight = this.getGridValue(integerPartX + 1, integerPartY);
        let fieldValue = 0;
        if (floatPartX + floatPartY > 1) {
            // Bottom right triangle
            const bottomRight = this.getGridValue(integerPartX + 1, integerPartY + 1);
            fieldValue = (floatPartX + floatPartY - 1) * bottomRight +
                (1 - floatPartX) * bottomLeft + (1 - floatPartY) * topRight;
        }
        else {
            // Top left triangle
            const topLeft = this.getGridValue(integerPartX, integerPartY);
            fieldValue = (1 - floatPartX - floatPartY) * topLeft +
                floatPartX * topRight + floatPartY * bottomLeft;
        }
        return this.object.getZ() + fieldValue * this.object.getDepth();
    }

    /**
     * @param texture {PIXI.Texture}
     * @param offsetX {integer}
     * @param offsetY {integer}
     */
    loadHeightMapFromTexture(texture, offsetX, offsetY) {
        const imageData = getImageData(texture, offsetX, offsetY, this.dimX, this.dimY);
        const size = this.dimX * this.dimY;
        const colorRange = 0xffff;
        const colorScale = defaultDepth / colorRange;

        for (
            let verticeIndex = 0, colorIndex = 0;
            verticeIndex < 3 * size;
            verticeIndex += 3, colorIndex += 4) {
            // Read red and green channel to workaround canvas being 8 bits.
            // Users need to convert their 16 bits grayscale images into 8 bits RGB.
            const color = 256 * imageData.data[colorIndex] + imageData.data[colorIndex + 1];
            this.vertices[verticeIndex + 2] = color * colorScale;
        }
        this.setAsDirty(true);
    }

    /**
     * @param resourceName {string}
     */
    loadTexture(resourceName) {
        if (!resourceName || resourceName === '') {
            // If no texture path, use a default white color
            /** @type {THREE.MeshStandardMaterial} */
            const material = this.mesh.material;
            material.color.set(0xffffff);
            material.map = null;
            material.needsUpdate = true;
            return;
        }

        // Get the runtime scene to access resources
        const runtimeScene = this.object.getInstanceContainer();
        
        try {
            // Get the PIXI texture from GDevelop's image manager
            const imageManager = runtimeScene.getGame().getImageManager();
            const pixiTexture = imageManager.getPIXITexture(resourceName);
            
            if (!pixiTexture || !pixiTexture.baseTexture || !pixiTexture.baseTexture.resource) {
                console.error('Could not load PIXI texture for:', resourceName);
                return;
            }
            
            // Get the URL from the PIXI texture
            const textureUrl = pixiTexture.baseTexture.resource.url;
            console.log('Loading texture from URL:', textureUrl);
            
            const loader = new THREE.TextureLoader();
            
            loader.load(
                textureUrl,
                (texture) => {
                    // Texture loaded successfully
                    console.log('Texture loaded successfully!');
                    this.texture = texture;
                    
                    // Set texture to repeat
                    texture.wrapS = THREE.RepeatWrapping;
                    texture.wrapT = THREE.RepeatWrapping;
                    
                    // Set how many times to repeat (adjust these values as needed)
                    // Higher numbers = more repeats = smaller texture appearance
                    texture.repeat.set(24, 24);
                    
                    // Set texture encoding to match GDevelop's rendering
                    texture.encoding = THREE.sRGBEncoding;
                    
                    /** @type {THREE.MeshStandardMaterial} */
                    const material = this.mesh.material;
                    material.map = texture;
                    material.color.set(0xffffff); // Reset to white
                    material.needsUpdate = true;
                },
                undefined,
                (error) => {
                    console.error('Error loading texture:', error);
                    console.error('Texture URL:', textureUrl);
                }
            );
        } catch (error) {
            console.error('Error accessing texture resource:', error);
            console.error('Resource name:', resourceName);
        }
    }

    /**
     * @param resourceName {string}
     */
    setColor(resourceName) {
        // Renamed from setColor but keeping the same function name for compatibility
        // Now it loads a texture instead
        this.loadTexture(resourceName);
    }

    /**
     * @param wireframe {boolean}
     */
    setWireframe(wireframe) {
        /** @type {THREE.MeshStandardMaterial} */
        const material = this.mesh.material;
        material.wireframe = wireframe;
    }
}

/**
 * @implements {gdjs.Physics3DRuntimeBehavior.BodyUpdater}
 */
class PhysicsHeightMap {
    /** @type {gdjs.Physics3DRuntimeBehavior} */
    physics;
    /** @type {HeightMap} */
    heightMap;

    /**
     * @param physics {gdjs.Physics3DRuntimeBehavior}
     * @param heightMap {HeightMap}
     */
    constructor(physics, heightMap) {
        this.physics = physics;
        this.heightMap = heightMap;

        this.physicsBodyUpdater = physics.bodyUpdater;
        physics.bodyUpdater = this;
        physics.recreateBody();
    }

    updateIfNeeded() {
        if (this.heightMap.hasChangedThisFrame) {
            this.physics.recreateBody();
        }
    }

    /**
     * @returns {Jolt.Body | null}
     */
    createAndAddBody() {
        const { physics } = this;
        const { _sharedData } = physics;
        const { dimX, dimY, vertices, object } = this.heightMap;

        const shapeSettings = new Jolt.HeightFieldShapeSettings();
        const width = object.getWidth() * _sharedData.worldInvScale;
        const height = object.getHeight() * _sharedData.worldInvScale;
        const depth = object.getDepth() * _sharedData.worldInvScale;
        shapeSettings.mOffset.Set(-width / 2, -depth / 2, -height / 2);
        shapeSettings.mScale.Set(
            width / (dimX - 1),
            object.getDepth() / defaultDepth,
            height / (dimY - 1),
        );
        shapeSettings.mSampleCount = dimX;
        const totalSize = dimX * dimY;
        shapeSettings.mHeightSamples.resize(totalSize);
        const heightSamples = new Float32Array(
            Jolt.HEAPF32.buffer,
            Jolt.getPointer(shapeSettings.mHeightSamples.data()),
            totalSize);
        for (let index = 0; index < heightSamples.length; index++) {
            heightSamples[index] = vertices[2 + 3 * index] * _sharedData.worldInvScale;
            // TODO Handle holes
            // Jolt.HeightFieldShapeConstantValues.prototype.cNoCollisionValue;
        }
        const shape = shapeSettings.Create().Get();
        Jolt.destroy(shapeSettings);
        const scaledShape = shape.ScaleShape(_sharedData.getVec3(1, 1, -1)).Get();

        const position = physics._getPhysicsPosition(_sharedData.getRVec3(0, 0, 0));
        const rotation = this._getPhysicsRotation(_sharedData.getQuat(0, 0, 0, 1));

        const creationSettings = new Jolt.BodyCreationSettings(
            scaledShape, position, rotation, Jolt.EMotionType_Static, physics.getBodyLayer());
        const body = _sharedData.bodyInterface.CreateBody(creationSettings);
        Jolt.destroy(creationSettings);
        _sharedData.bodyInterface.AddBody(
            body.GetID(),
            Jolt.EActivation_Activate
        );
        return body;
    }

    /**
     * @param result {Jolt.Quat}
     */
    _getPhysicsRotation(result) {
        const threeObject = this.heightMap.object.get3DRendererObject();
        // TODO Use a static variable for the quaternion.
        // It's not great to use the object rotation directly.
        threeObject.rotation.x += Math.PI / 2;
        result.Set(
            threeObject.quaternion.x,
            threeObject.quaternion.y,
            threeObject.quaternion.z,
            threeObject.quaternion.w
        );
        threeObject.rotation.x -= Math.PI / 2;
        return result;
    }

    updateObjectFromBody() {
        const { physics } = this;
        const { _body } = physics;
        // Copy transform from body to the GD object.
        // The body is null when the behavior was either deactivated or the object deleted.
        // It would be useless to try to recreate it as updateBodyFromObject already does it.
        // If the body is null, we just don't do anything
        // (but still run the physics simulation - this is independent).
        if (_body !== null && _body.IsActive()) {
            physics._moveObjectToPhysicsPosition(_body.GetPosition());
            this._moveObjectToPhysicsRotation(_body.GetRotation());
        }
    }

    /**
     * @param physicsRotation {Jolt.Quad}
     */
    _moveObjectToPhysicsRotation(physicsRotation) {
        const { object } = this.heightMap;

        const threeObject = object.get3DRendererObject();
        threeObject.quaternion.x = physicsRotation.GetX();
        threeObject.quaternion.y = physicsRotation.GetY();
        threeObject.quaternion.z = physicsRotation.GetZ();
        threeObject.quaternion.w = physicsRotation.GetW();
        // TODO Avoid this instantiation
        const euler = new THREE.Euler(0, 0, 0, 'ZYX');
        euler.setFromQuaternion(threeObject.quaternion);
        object.setRotationX(gdjs.toDegrees(euler.x) - 90);
        object.setRotationY(gdjs.toDegrees(euler.y));
        object.setAngle(gdjs.toDegrees(euler.z));
    }

    updateBodyFromObject() {
        const { object } = this.heightMap;
        const { physics } = this;
        const { _sharedData } = physics;
        if (physics._body === null) {
            if (!physics._createBody()) return;
        }
        const body = physics._body;

        if (
            physics._objectOldX !== object.getX() ||
            physics._objectOldY !== object.getY() ||
            physics._objectOldZ !== object.getZ() ||
            physics._objectOldRotationX !== object.getRotationX() ||
            physics._objectOldRotationY !== object.getRotationY() ||
            physics._objectOldRotationZ !== object.getAngle()
        ) {
            _sharedData.bodyInterface.SetPositionAndRotationWhenChanged(
                body.GetID(),
                physics._getPhysicsPosition(_sharedData.getRVec3(0, 0, 0)),
                this._getPhysicsRotation(_sharedData.getQuat(0, 0, 0, 1)),
                Jolt.EActivation_Activate
            );
        }
    }

    recreateShape() {
        this.physicsBodyUpdater.recreateShape();
    }

    destroyBody() {
        this.physicsBodyUpdater.destroyBody();
    }
}

/**
 * @param texture {PIXI.Texture}
 * @param offsetX {integer}
 * @param offsetY {integer}
 * @param width {integer}
 * @param height {integer}
 */
function getImageData(texture, offsetX, offsetY, width, height) {
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    /** @type {PIXI.Rectangle} */
    const crop = texture._frame.clone();

    crop.x += offsetX;
    crop.y += offsetY;
    crop.width = Math.min(crop.width, width);
    crop.height = Math.min(crop.height, height);

    // TODO Should the resolution be taken into account?
    // /** @type {number} */
    // const resolution = texture.baseTexture.resolution;
    // crop.x *= resolution;
    // crop.y *= resolution;
    // crop.width *= resolution;
    // crop.height *= resolution;

    canvas.width = width;
    canvas.height = height;

    context.save();
    context.globalCompositeOperation = 'copy';
    context.drawImage(
        texture.baseTexture.getDrawableSource(),
        crop.x,
        crop.y,
        crop.width,
        crop.height,
        0,
        0,
        crop.width,
        crop.height
    );
    context.restore();

    return context.getImageData(0, 0, width, height);
}

gdjs.__heightMap3DExtension = {
    HeightMap,
    PhysicsHeightMap,
};

You also need to modify HeightMap objects properties so it want’s a texture instead of a color.


Like this.


After it should want a texture instead of a color and it should look like this.
Inside the DefineHelperClasses function’s Javascript code you can edit how much the texture should repeat.

texture.repeat.set(24, 24);

I have it set to repeat 24x24.

2 Likes

Usually, 3D models are used for the visual aspect and the height map for collisions.