Realistic Photography Camera
This is a shader + script for Godot 4.6+ that attempts to emulate most of the settings in a DSLR/film camera. A camera like this is really fun to run around with in almost any 3D game!
It is worth mentioning that this is not 100% realistic, as Godot lets us have more control than a normal camera would have. Also I had to do a lot of research for this shader, so im sure I got some things wrong!
Features
● F-Stop, controlling depth of feild, exposure, and noise.
● Shutter speed, controlling motion blur, exposure, and noise. Supports long shutter speeds by stacking frames on top of each other to produce proper motion blur and reduced noise.
● ISO affecting exposure and read noise.
● Exposure calculation based realistically off of f-stop, shutter speed, iso, and base EV
● Blobby, film-like noise that is applied realistically based on fstop, shutter speed, and ISO settings.
● Supports camera zoom and setting focal distance
● Saves pictures to an output directory, so players can look back on them, share them, or you can have them loaded later in your game
● All image processing and saving is done on a seperate thread to prevent any freezing when taking pictures
● Customizable drawn UI to display center, rule of thirds, and frame guides – automatically adjusts with settings.
NOTE: Currently only Manual mode is supported, but I plan to add auto and semi-auto in the future!
How To Use
Check out the example godot project if you have any issues -> https://github.com/TuniTem/shaders-demo
1. Create a new scene, and make a hierarchy that looks like the one below:
Texture Rect [Anchors Preset = Full Rect] [create realsitic_camera.gd script on this] [Add a viewport texture and set it to the new SubViewport after it is created]
⤷ SubViewport
⤷ Camera3D [create camera_accumulation.gd script on this]
⤷ WorldEnviroment
⤷ ColorRect [Anchors Preset = Full Rect] [Create shader material w/ realistic_camera_effects.gdshader]
2. Copy/paste the code:
realsitic_camera.gd:
@tool
extends TextureRect
class_name RealisticCamera
enum Mode {
MANUAL,
#SEMI_AUTOMATIC,
#AUTO
}
enum Aspect {
SQUARE,
STANDARD,
HIGH
}
const MAX_RENDER_DISTANCE = 4000.0
const APERATURE_DIAMETER = 450.0
const AUTO_FOCUS_SPEED = 1.0
const ASSUMED_FPS = 60.0
const MAX_MOTION_BLUR_AMOUNT = 0.3365
const ASPECT_TO_RATIO : Dictionary[Aspect, float] = {
Aspect.SQUARE : 1.0,
Aspect.STANDARD : 4.0 / 3.0,
Aspect.HIGH : 16.0 / 9.0
}
## The location that photos will be saved, has to be in the user:// directory
## if you want someting outside the user dir you need to rewrite the verify_dir() function
@export var photo_save_location : String = "user://"
## The name of the created file, you can use the tags
## [year], [month], [day], [hour], [minute], [second] to insert a timestamp
@export var photo_save_name : String = "Photo [month]-[day]-[year]T[hour]-[minute]-[second]"
@export var viewport_size : Vector2 = Vector2(1920, 1080):
set(val):
viewport_size = val
if not Engine.is_editor_hint(): await RenderingServer.frame_post_draw
viewport.size = viewport_size
## How far into the frame the photo should be taken, enable [param draw_aspect_corners] to see the effect clearly
@export_range(0.0, 0.95, 0.001) var aspect_inset_amount : float
@export_category("Camera Settings")
## The focal point, aka where depth of feild is focused on
@export_range(0.01, MAX_RENDER_DISTANCE, 0.001, "exp", "suffix:m") var focal_distance : float = 1.0:
set(val):
focal_distance = val
if not Engine.is_editor_hint(): await RenderingServer.frame_post_draw
camera_attributes.frustum_focus_distance = focal_distance
## The angle of view (aka FOV) of the camera
@export_range(1.0, 179.0, 0.001, "exp", "degrees") var zoom : float = 30.0:
set(val):
zoom = val
if not Engine.is_editor_hint(): await RenderingServer.frame_post_draw
camera.fov = zoom
## A smaller Fstop means a shallower depth of feild and a brighter image
@export_range(1.0, 32.0, 0.001, "exp") var fstop : float = 4.0:
set(val):
fstop = val
if not Engine.is_editor_hint(): await RenderingServer.frame_post_draw
shader.set_shader_parameter("fstop", fstop)
camera_attributes.frustum_focal_length = APERATURE_DIAMETER / fstop
## A smaller shutter speed means a darker image and less motion blur
@export_range(1.0 / 60.0, 4.0, 0.00001, "exp", "suffix:s") var shutter_speed : float = 1.0 / 60.0:
set(val):
shutter_speed = val
if not Engine.is_editor_hint(): await RenderingServer.frame_post_draw
shader.set_shader_parameter("shutter_speed", shutter_speed)
## A smaller ISO means a darker image, which tends to lead to less grain
@export_range(100.0, 25600.0, 10.0, "exp") var iso : float = 400.0:
set(val):
iso = val
if not Engine.is_editor_hint(): await RenderingServer.frame_post_draw
shader.set_shader_parameter("iso", iso)
## The mode of the camera, currently only manual is supported as I was having issues with the others
@export var mode : Mode = Mode.MANUAL:
set(val):
mode = val
if not Engine.is_editor_hint(): await RenderingServer.frame_post_draw
match mode:
#Mode.AUTO:
#iso = 400
#shutter_speed = 1.0 / 30.0
#fstop = 1.5
#camera_attributes.auto_exposure_enabled = true
#auto_raycast.enabled = true
#
#Mode.SEMI_AUTOMATIC:
#iso = 400
#camera_attributes.auto_exposure_enabled = true
#auto_raycast.enabled = false
Mode.MANUAL:
camera_attributes.auto_exposure_enabled = false
## The aspect ratio of the final shot, enable [param draw_aspect_corners] to see the effect clearly
@export var aspect_ratio : Aspect = Aspect.STANDARD
@export_category("Drawing")
## Draws the corners of the taken frame
@export var draw_aspect_corners : bool = true
@export var aspect_corners_color : Color = Color.WHITE
@export_range(0.0, 1.0, 0.001) var aspect_corners_length : float = 0.05
## Draws a rule of third guide
@export var draw_rule_of_thirds : bool = false
@export var rule_of_thirds_color : Color = Color.WHITE
@export_range(0.0, 1.0, 0.001) var rule_of_thirds_length : float = 0.05
## Draws a crosshair at the center of the camera
@export var draw_crosshair : bool = false
@export var crosshair_color : Color = Color.WHITE
@export_range(0.0, 128.0, 0.001) var crosshair_length : float = 20.0
## The line width of all draw calls
@export_range(-1.0, 16.0, 1.0) var line_width : int = 2
@export_category("Nodes")
## A node who's transform will be copied to the camera's transform.
## Basically think about it as the object representing the realistic camera.
@export var camera_transform_track : Node3D
## The camera in the subviewport
@export var camera : Camera3D
## The default camera of the game, enabled and disabled as the realistic camera is toggled
@export var default_camera : Camera3D
## The camera atributes thats used on the world enviroment node, doesn't need to be modified
@export var camera_attributes : CameraAttributesPhysical
## The screenspace effects shader placed on a color rect in the subviewport
@export var shader : ShaderMaterial
## The subviewport where the cameras output will be rendered
@export var viewport : SubViewport
## The world enviroment in the viewport
@export var environment : WorldEnvironment
## if a picture is currently being taken
var capturing : bool = false
## if the ui is shown
var active : bool = false
# multithreading
const THREAD_TIMEOUT_TIME = 90.0
var _thread : Thread = Thread.new()
var _thread_timeout : float = 0.0
var _quit_thread_flag : int
func _ready() -> void:
# Set the max render distance
camera_attributes.frustum_far = MAX_RENDER_DISTANCE
camera.far = MAX_RENDER_DISTANCE
# set the subviewport's options
viewport.use_taa = true
viewport.use_debanding = true
# make sure the output directory exists if the game is open
if not Engine.is_editor_hint(): _verify_dir(photo_save_location)
# disable the camera by default
enable_visual(false)
func _process(delta: float) -> void:
# copy the remote transform's global transform to the subviewport camera
if camera_transform_track: camera.global_transform = camera_transform_track.global_transform
queue_redraw()
func _draw() -> void:
# some draw call magic
if draw_crosshair:
for line : Vector2 in [Vector2.UP, Vector2.DOWN, Vector2.LEFT, Vector2.RIGHT]:
draw_line(size / 2.0, size / 2.0 + line * crosshair_length, crosshair_color, line_width)
if draw_aspect_corners or draw_rule_of_thirds:
var aspect : float = ASPECT_TO_RATIO[aspect_ratio]
var height : int = roundi(size.y * (1.0 - aspect_inset_amount))
var width : int = roundi(height * aspect)
var image_dimentions : Vector2 = Vector2(width, height)
var center : Vector2 = size * 0.5
var length : float = lerpf(0.0, width, aspect_corners_length)
if draw_aspect_corners:
for line : Vector2 in [Vector2(0.5, 0.5), Vector2(-0.5, 0.5), Vector2(-0.5, -0.5), Vector2(0.5, -0.5)]:
var origin : Vector2 = center + image_dimentions * line
draw_line(origin, origin + Vector2(-line.x, 0.0) * length, aspect_corners_color, line_width)
draw_line(origin, origin + Vector2(0.0, -line.y) * length, aspect_corners_color, line_width)
if draw_rule_of_thirds:
length = lerpf(0.0, width, rule_of_thirds_length)
var vertical_length : float = clamp(length, 0.0, image_dimentions.y / 3.0)
length = clamp(length, 0.0, image_dimentions.x / 3.0)
var sixth : float = 1.0 / 6.0
for line : Vector2 in [Vector2(sixth, sixth), Vector2(-sixth, sixth), Vector2(-sixth, -sixth), Vector2(sixth, -sixth)]:
var origin : Vector2 = center + image_dimentions * line
draw_line(origin - Vector2(line.x, 0.0).normalized() * length, origin + Vector2(line.x, 0.0).normalized() * length, rule_of_thirds_color, line_width)
draw_line(origin - Vector2(0.0, line.y).normalized() * vertical_length, origin + Vector2(0.0, line.y).normalized() * vertical_length, rule_of_thirds_color, line_width)
## function used to queue threads for image processing and saving, so the main game doesnt freeze
func _queue_thread(callable : Callable, args : Array = [], custom_timeout : float = -1.0, priority : Thread.Priority = Thread.Priority.PRIORITY_NORMAL) -> Variant:
while _thread.is_alive():
await get_tree().process_frame
_thread.start(callable.bindv(args), priority)
_thread_timeout = THREAD_TIMEOUT_TIME if custom_timeout == -1.0 else custom_timeout
while _thread.is_alive() and _thread_timeout > 0.0:
_thread_timeout -= get_process_delta_time()
await get_tree().process_frame
if _thread_timeout <= 0.0:
printerr("Thread timed out: " + str(callable))
_quit_thread_flag = callable.get_object().get_instance_id()
return null
else:
return _thread.wait_to_finish()
## make sure the inputed directory exists, only check in user://
func _verify_dir(path : String):
path = path.replace("user://", "")
var dir = DirAccess.open("user://")
if not dir.dir_exists(path):
var files : Array = path.split("/")
var curr_dir = files.pop_front()
for file in files:
dir.make_dir(curr_dir)
curr_dir = curr_dir + "/" + file
## enable/disable the camera overlay
func enable_visual(on : bool):
camera.current = on
default_camera.current = !on
visible = on
active = on
if on:
environment.camera_attributes = camera_attributes
else:
environment.camera_attributes = null
## toggle the camera overlay
func toggle_visual():
enable_visual(!active)
## capture a photo, and return an image object. If [param ignore_capture_check] is true,
## multiple pictures can be taken at once before previous ones have processed
func capture(ignore_capture_check : bool = false):
if not capturing or ignore_capture_check:
capturing = true
var aspect : float = ASPECT_TO_RATIO[aspect_ratio]
var height : int = roundi(viewport.size.y * (1.0 - aspect_inset_amount))
var width : int = roundi(height * aspect)
var output : Image
var base_format : Image.Format
var operation_format : Image.Format = Image.Format.FORMAT_RGBF
var frame : int = roundi(ASSUMED_FPS * shutter_speed)
var frames : Array[Image]
if frame > 1:
shader.set_shader_parameter("motion_blur_enabled", true)
camera.strength = MAX_MOTION_BLUR_AMOUNT
for i in range(frame):
await RenderingServer.frame_post_draw
var img : Image = viewport.get_texture().get_image()
if i == 0: base_format = img.get_format()
frames.append(img)
shader.set_shader_parameter("motion_blur_enabled", false)
if frame > 0:
output = await _queue_thread(_combine_frames, [frames, width, height, operation_format, viewport.size], 120.0, Thread.PRIORITY_HIGH)
print("Combined image layers...")
output.convert(base_format)
capturing = false
return output
## captures an image fromthe camera and saves it to a specifiecd path (or the default path if none is given)
## If [param ignore_capture_check] is true, multiple pictures can be taken at once before previous ones have processed
func capture_and_save(ignore_capture_check : bool = false, path : String = ""):
if not capturing or ignore_capture_check:
var image : Image = await capture(ignore_capture_check)
capturing = true
var time = Time.get_time_dict_from_system()
var date = Time.get_date_dict_from_system()
var file_name : String = photo_save_name
for replace : Array in [["[month]", date.month], ["[day]", date.day], ["[year]", date.year], ["[hour]", time.hour], ["[minute]", time.minute], ["[second]", time.second]]:
file_name = file_name.replace(replace[0], str(replace[1]))
file_name += ".png"
await _queue_thread(_save_photo.bindv([image, (photo_save_location if path == "" else path) + "/" + file_name]))
print("Photo saved to ", ProjectSettings.globalize_path((photo_save_location if path == "" else path) + "/" + file_name))
capturing = false
## Save the photo to the specified path, in a sperate func to be multithreaded
func _save_photo(image : Image, path : String):
image.save_png(path)
## if the image has a large shutter speed, this layers multiple fromes on top of eachother to emulate that effect
func _combine_frames(frames : Array[Image], width : int, height : int, format : Image.Format, viewport_size : Vector2i) -> Image:
var output = Image.create_empty(width, height, false, format)
output.fill(Color.BLACK)
var frame : float = float(frames.size())
for img : Image in frames:
img.convert(format)
var cropped : Image = Image.create_empty(width, height, false, format)
cropped.blit_rect(img, Rect2i((Vector2(viewport_size) - Vector2(width, height)) * 0.5, Vector2(width, height)), Vector2i.ZERO)
# TODO have image layers accumulate on the gpu in some way instead of iterating over pixels, multithreading it is a meh workaround
# could use a subviewport on rendermode no clear and then stack transparent frames with that maybe? Or a accumulation shader
# if you figure this out, open an issue/pr on the demo github or contact me: tunitem (discord)
for x in range(width):
if _quit_thread_flag == get_instance_id(): return
for y in range(height):
output.set_pixel(x, y, output.get_pixel(x, y) + cropped.get_pixel(x,y) / frame)
return output
camera_accumulation.gd:
extends Camera3D
# Motion blur controller modified from https://godotshaders.com/shader/3d-camera-smooth-motion-blur/
@export_range(0.0, 1.0) var strength: float = 0.3365
@export_range(4, 32) var blur_samples: int = 16
@export_range(0.0, 1.0) var smoothing: float = 0.6056
@export var shader : ShaderMaterial
var prev_pos := Vector3.ZERO
var prev_basis := Basis()
var current_blur := Vector2.ZERO
func _ready() -> void:
shader.set_shader_parameter("samples", blur_samples)
prev_pos = global_position
prev_basis = global_transform.basis
func _physics_process(delta) -> void:
if delta <= 0: return
var linear_vel = (global_position - prev_pos) / delta
var delta_basis = prev_basis.inverse() * global_transform.basis
var delta_quat = Quaternion(delta_basis)
var angular_vel := Vector3.ZERO
if abs(delta_quat.w) < 1.0:
var half_angle = acos(clamp(delta_quat.w, -1.0, 1.0))
if half_angle > 0.0001:
var sin_half = sin(half_angle)
angular_vel = Vector3(delta_quat.x, delta_quat.y, delta_quat.z) / sin_half * (2.0 * half_angle / delta) * 5.0
var local_vel = global_transform.basis.inverse() * linear_vel
var raw_blur = Vector2(
-angular_vel.y - local_vel.x,
angular_vel.x + local_vel.y
) * strength * delta
var t = 1.0 - pow(smoothing, delta * 60.0)
current_blur = current_blur.lerp(raw_blur, t)
shader.set_shader_parameter("blur_direction", current_blur)
prev_pos = global_position
prev_basis = global_transform.basis
realistic_camera_effects.gdshader is found in the shader code at the bottom of the page.
3. Place the newly created scene wherever you plan on using it, I reccomend having it wherever you have your main camera (so usually in player.tscn) or in a UI node.
4. Assign all the values
realsitic_camera.gd (on the root TextureRect):
● Camera Transform Track: A 3D node that the realistic camera will be locked to.
● Camera: The camera in your created subviewport
● Default Camera: The general camera that is used when not inside the realistic camera menu, not required, but the game will be rendered twice if not set.
● Camera Attributes: Used for depth of feild, just create a new camera attributes and leave it in there
● Shader: The ShaderMaterial on the newly created ColorRect
● Viewport: The newly created SubViewport
● Environment: The newly created WorldEnvironment
● Concider the settings for various overlays in the “Drawing” section
camera_accumulation.gd (on the new Camera3D):
● Shader: The ShaderMaterial on the newly created ColorRect
realistic_camera_effects.gdshader (The ShaderMaterial on the newly created ColorRect)
● feel free to mess around with the different color’s intensities/scales in the “Noise Settings” tab of the shader uniforms to get a flavor of noise that works well for your game.
● You can change the Base EV depending on how dark/bright your game is.
5. Create an implementation of the settings for your game.
This should be pretty easy! I tried to make it as straightforward as possible. Here it the minimum viable implementation of the camera. In the demo project I have this little snipbit in the character controller:
#Assuming the RealisticCamera is a child of the player
@onready var realistic_camera: RealisticCamera = %RealisticCamera
func _input(event: InputEvent) -> void:
if event.is_action_pressed("camera"):
realistic_camera.toggle_visual()
if event.is_action_pressed("take_picture") and realistic_camera.active:
realistic_camera.capture_and_save(true)
Here is a list of all of the variables/functions you may or may not want to expose in your UI:
– func toggle_visual(), toggles whether the camera UI is shown or not
– func enable_visual(), sets the camera UI to on or off
– func capture_and_save(), takes a picture with the current camera settings and saves it to either the path at photo_save_location or a specified path fed into the function. Call with await.
– func capture(), takes a picture with the current camera settings and returns it as an image. Call with await.
– Zoom, (basically, FOV) you could bind this to a scroll action!
– Focal Distance (where the depth of feild is focused on)
– Fstop
– Shutter Speed
– ISO
– Aspect Ratio
– You may want to let the player enable/disable some of the overlays in the “Drawing” section
Let me know if you encounter any issues or if you make any games with this! But please be nice 🙏
Shader code
shader_type canvas_item;
uniform sampler2D screen_texture : hint_screen_texture, filter_linear_mipmap;
uniform vec2 resolution = vec2(1920, 1080);
group_uniforms Camera_Settings;
uniform float iso : hint_range(100, 25600, 100) = 400;
uniform float fstop : hint_range(1.0, 32.0, 0.1) = 2.8;
uniform float shutter_speed : hint_range(0.016667, 4.0, 0.0001) = 0.004;
uniform float set_ev : hint_range(-5.0, 5.0, 0.001) = 0.0;
group_uniforms Exposure;
uniform float base_ev = 17.621;
group_uniforms MotionBlur;
uniform bool motion_blur_enabled = false;
uniform vec2 blur_direction = vec2(0.0, 0.0);
uniform int samples : hint_range(4, 32) = 32;
group_uniforms Noise_Settings;
uniform float noise_intensity : hint_range(0, 1.0, 0.001) = 0.5;
uniform float max_noise : hint_range(0.1, 2.0, 0.001) = 1.0;
group_uniforms Red;
uniform float r_intensity : hint_range(0, 2.0, 0.001) = 1.0;
uniform float r_scale : hint_range(0.1, 0.6, 0.001) = 0.472;
group_uniforms Green;
uniform float g_intensity : hint_range(0, 2.0, 0.001) = 0.667;
uniform float g_scale : hint_range(0.1, 0.6, 0.001) = 0.6;
group_uniforms Blue;
uniform float b_intensity : hint_range(0, 2.0, 0.001) = 1.213;
uniform float b_scale : hint_range(0.1, 0.6, 0.001) = 0.41;
// hash22 random taken from https://www.shadertoy.com/view/lldyDn w/ a seed added
vec2 hash22(vec2 uv, float seed) {
uv += seed;
vec3 p3 = fract(vec3(uv.xyx) * vec3(0.1031, 0.1030, 0.0973));
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.xx + p3.yz) * p3.zy) * 2.0 - 1.0;
}
// perlin noise from https://godotshaders.com/snippet/2d-noise/
float noise(vec2 uv, float s) {
vec2 uv_index = floor(uv);
vec2 uv_fract = fract(uv);
vec2 blur = smoothstep(0.0, 1.0, uv_fract);
return mix(
mix(dot(hash22(uv_index + vec2(0.0, 0.0), s), uv_fract - vec2(0.0, 0.0)),
dot(hash22(uv_index + vec2(1.0, 0.0), s), uv_fract - vec2(1.0, 0.0)), blur.x),
mix(dot(hash22(uv_index + vec2(0.0, 1.0), s), uv_fract - vec2(0.0, 1.0)),
dot(hash22(uv_index + vec2(1.0, 1.0), s), uv_fract - vec2(1.0, 1.0)), blur.x),
blur.y) + 0.5;
}
void fragment() {
vec2 uv = SCREEN_UV;
// motion blur modified from https://godotshaders.com/shader/3d-camera-smooth-motion-blur/
vec4 pixel_color;
if (motion_blur_enabled) {
vec4 color = vec4(0.0);
float total_weight = 0.0;
for (int i = 0; i < samples; i++) {
float offset = float(i) / float(samples - 1) + 0.5;
vec2 uv_offset = blur_direction * offset;
color += texture(screen_texture, uv + uv_offset);
total_weight += 1.0;
}
pixel_color = color / total_weight;
} else {
pixel_color = texture(screen_texture, uv);
}
// exposure calculation
uv.y *= resolution.y / resolution.x;
float light_gathering = (1.0 / (fstop * fstop)) * shutter_speed;
float scene_ev = set_ev + base_ev + log2(light_gathering * iso / 100.0);
float exposure_multiplier = pow(2.0, scene_ev - 12.0);
pixel_color.rgb *= exposure_multiplier;
// blobby noise using perlin func
float photon_noise_factor = sqrt(1.0 / max(light_gathering, 0.0001));
float read_noise_factor = sqrt(iso / 100.0);
float noise_amplifier = photon_noise_factor * read_noise_factor;
vec3 delta_color = vec3(
(noise(uv * resolution.y * r_scale, 1.23 + TIME) - 0.5) * 2.0 * r_intensity,
(noise(uv * resolution.y * g_scale, 4.32 + TIME) - 0.5) * 2.0 * g_intensity,
(noise(uv * resolution.y * b_scale, 5.25 + TIME) - 0.5) * 2.0 * b_intensity
) * noise_intensity * min(noise_amplifier * 0.01, max_noise);
pixel_color.rgb += delta_color;
COLOR = pixel_color;
}



