Completely forgot to post my shader yesterday. Anyway, here it is:
shader_type canvas_item;
uniform vec2 camera_position = vec2(0, 128);
uniform float camera_height = 512.0;
varying vec3 uvd;
void vertex() {
// using VERTEX.y as height component:
vec3 position_3d = vec3(VERTEX, 0.0);
// perspective line only on 2d xz plane (3d simply not needed)
vec2 perspective_line = position_3d.xz - camera_position;
float height_diff = camera_height - position_3d.y;
float depth_factor = camera_height / height_diff;
// calc the vertex pos based on the perspective. moves along
// the perspective line outgoing from the camera pos.
VERTEX = camera_position + perspective_line * depth_factor;
// the important part to prevent shearing: multiplying the UV
// by the depth factor to be able to linearly interpolate over
// the 3d vector and then reconstructing the corrected UV in
// the fragment stage:
uvd = vec3(UV, 1.0) * depth_factor;
}
void fragment() {
vec2 corrected_uv = uvd.xy / uvd.z;
COLOR = texture(TEXTURE, corrected_uv);
}
It needs to be set as a shader material (down in the "CanvasItem" section in the inspector) for a 2d sprite (for example) or any other canvas item derived node.
My Sprite has the following options set:
- not centered (otherwise part of the node will look like it is "under ground" after the perspective transform)
- flip V (I use UV.y = 0 as the indicator for the "lower" edge of the sprite)
- offset can be kept at 0, 0. changing the x value will move the sprite left/right. changing the y value will move the sprite perspectively up/down. this can have some desirable effects
Currently it's impossible to get the global position of a node in canvas item shaders. Instead I pass in the local position of the camera as a uniform. For this you need to have a unique instance of the material per node. I use the following script to set the uniform:
extends Sprite
export var camera: NodePath
onready var _cam: Camera2D = get_node(camera)
func _process(_delta: float) -> void:
var camera_position := to_local(_cam.global_position)
material.set_shader_param('camera_position', camera_position)
In theory one could handle the perspective completely in view space. The world matrix could be used to do this. I just was too lazy to do so.
oh and you may need to calculate a custom z index. 2d rendering has no depth buffer, so objects are drawn in natural order. this is bad for 3d as things can overlap each other in different ways based on perspective. One way to fake this is to calculate the z index as follows each frame (it uses the same local camera pos as the script above, so you can simply add this line to the script if you need it):
z_index = int(max(screen_max - camera_position.length(), 0))