Godot Project Settings
- Go to Project > Project Settings > Display > Window > Stretch > Mode and set it to "viewport".
- Go to Project > Project Settings > Display > Window > Stretch > Aspect and set it to "keep" *
- Go to Project > Project Settings > Rendering > Quality > 2D > Use Pixel Snap and turn this On.
- Make sure Pixel Snap is enabled for 2D View
- Whenever you add a Sprite or AnimatedSprite node, make sure to disable the "Centered" property. For the AnimatedSprite node you will find that property under "AnimatedSprite" and for the Sprite node you find it under "Offset".
- Change the default Rendering Texture in Project Settings rendering/textures/canvas_textures/default_texture_filter (you need to activate Advanced Settings), to Nearest instead of Linear.
Viewport Settings
Snap2D and Filter do not inherit, make sure they are set in the viewport.
3D Pixel Art Rendering
Based on the work of Denovodavid, there is a way to have 3D look like real pixel art.
3D Pixel Art Rendering - Camera
- Node3D (Parent)
- SubViewport (Camera Viewport) (322x182)
- Node3D (Camera Control)
- Camera3D
#Camera Control
extends Node3D
@export var circular_radius: float = 0.0
@export var circular_speed: float = 0.2
@export var cam: Camera3D
@export var follow: Node3D
@export var followlerp: bool
var selfx := 0.0
var selfz := 0.0
func _ready() -> void:
selfx = position.x
selfz = position.z
func _process(_delta: float) -> void:
if follow:
position.x = selfx + follow.position.x
position.z = selfz + follow.position.z
if followlerp:
position.x = selfx + lerp(follow.position.x, position.x, 0.02)
position.z = selfz + lerp(follow.position.z, position.z, 0.02)
if Input.is_action_pressed("ui_copy"):
cam.size = lerp(cam.size, 20.0, 0.2)
if Input.is_action_pressed("ui_cut"):
cam.size = lerp(cam.size, 10.0, 0.2)
#Camera3D
class_name Camera3DTexelSnapped3
extends Camera3D
@export var snap := true
@export var snap_objects := true
var texel_error := Vector2.ZERO
@onready var _prev_rotation := global_rotation
@onready var _snap_space := global_transform
var _texel_size: float = 0.0
var _snap_nodes: Array[Node]
var _pre_snapped_positions: Array[Vector3]
func _ready() -> void:
RenderingServer.frame_post_draw.connect(_snap_objects_revert)
self.rotation.y = 0.8853982
func _process(_delta: float) -> void:
self.rotation.y = 0.7853982
# rotation changes the snap space
if global_rotation != _prev_rotation:
_prev_rotation = global_rotation
_snap_space = global_transform
_texel_size = size / float((get_viewport() as SubViewport).size.y)
# camera position in snap space
var snap_space_position := global_position * _snap_space
# snap!
var snapped_snap_space_position := snap_space_position.snapped(Vector3.ONE * _texel_size)
# how much we snapped (in snap space)
var snap_error := snapped_snap_space_position - snap_space_position
if snap:
# apply camera offset as to not affect the actual transform
h_offset = snap_error.x
v_offset = snap_error.y
# error in screen texels (will be used later)
texel_error = Vector2(snap_error.x, -snap_error.y) / _texel_size
if snap_objects:
_snap_objects.call_deferred()
else:
texel_error = Vector2.ZERO
func _snap_objects() -> void:
_snap_nodes = get_tree().get_nodes_in_group("snap")
_pre_snapped_positions.resize(_snap_nodes.size())
for i in _snap_nodes.size():
var node := _snap_nodes[i] as Node3D
var pos := node.global_position
_pre_snapped_positions[i] = pos
var snap_space_pos := pos * _snap_space
var snapped_snap_space_pos := snap_space_pos.snapped(Vector3(_texel_size, _texel_size, 0.0))
node.global_position = _snap_space * snapped_snap_space_pos
func _snap_objects_revert() -> void:
for i in _snap_nodes.size():
(_snap_nodes[i] as Node3D).global_position = _pre_snapped_positions[i]
_snap_nodes.clear()
3D Pixel Art Rendering - Rendering To Screen
- Control Node
- Sprite2D
#Control Node
extends Control
@export var viewport: SubViewport
@export var pixel_movement := true
@export var sub_pixel_movement_at_integer_scale := true
@export var _sprite: Sprite2D
func _process(_delta: float) -> void:
var screen_size := Vector2(get_window().size)
# viewport size minus padding
var game_size := Vector2(viewport.size - Vector2i(2, 2))
var display_scale := screen_size / game_size
# maintain aspect ratio
var display_scale_min: float = minf(display_scale.x, display_scale.y)
_sprite.scale = Vector2(display_scale_min, display_scale_min)
# scale and center control node
size = (_sprite.scale * game_size).round()
position = ((screen_size - size) / 2).round()
# smooth!
if pixel_movement:
var cam := viewport.get_camera_3d() as Camera3DTexelSnapped3
var pixel_error: Vector2 = cam.texel_error * _sprite.scale
_sprite.position = -_sprite.scale + pixel_error
var is_integer_scale := display_scale == display_scale.floor()
if is_integer_scale and not sub_pixel_movement_at_integer_scale:
_sprite.position = _sprite.position.round()
// Sprite2D Shader Material
// based on code by t3ssel8r: https://youtu.be/d6tp43wZqps
// adapted to Godot by denovodavid
shader_type canvas_item;
render_mode unshaded;
void fragment() {
// box filter size in texel units
vec2 box_size = clamp(fwidth(UV) / TEXTURE_PIXEL_SIZE, 1e-5, 1);
// scale uv by texture size to get texel coordinate
vec2 tx = UV / TEXTURE_PIXEL_SIZE - 0.5 * box_size;
// compute offset for pixel-sized box filter
vec2 tx_offset = smoothstep(vec2(1) - box_size, vec2(1), fract(tx));
// compute bilinear sample uv coordinates
vec2 uv = (floor(tx) + 0.5 + tx_offset) * TEXTURE_PIXEL_SIZE;
// sample the texture
COLOR = textureGrad(TEXTURE, uv, dFdx(UV), dFdy(UV));
}
Based on work from Denovodavid (SourceHut, 2024, Sep 29)