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;
}
Live Preview
Tags
atmospheric, camera, noise, photo, photography, retro
The shader code and all code snippets in this post are under CC0 license and can be used freely without the author's permission. Images and videos, and assets depicted in those, do not fall under this license. For more info, see our License terms.

Related shaders

guest

0 Comments
Oldest
Newest Most Voted
Inline Feedbacks
View all comments