【Shadow Mapping】ShadowTactics/Desperado/Commando Enemy FOV

Update (2026) — single shadow map + dual-height sampling

 

This rewrite drops the **second (half-FOV) depth camera** entirely. Everything the old dual-camera setup did is now handled by **one shadow map sampled at two heights**.

 

**What changed & why**

 

– **Crouch vs. standing is now per-pixel and geometry-correct.** Instead of a second camera, the decal shader projects each ground point into the single shadow map twice — once raised to standing head height (`1.8`) and once to crouch height (`0.8`), exactly like Shadow Tactics’ `_fOffsetHigh` / `_fOffsetLow`. The *difference* between the two tests is precisely the “crouch behind low cover to hide” zone.

– **The cone now climbs elevated terrain correctly.** The depth camera’s **vertical FOV is decoupled** from the cone’s horizontal angle (widened to ~135°). Previously, when a low guard looked up at high ground, the high surface fell outside the shadow frustum and the decal broke. ST uses a 135° vertical shadow camera for the same reason.

– One less SubViewport + compute pass per FOV.

 

**How the dual-height test works**

 

For each screen pixel, the shader reconstructs the world point on the ground, then asks the single shadow map two questions:

 

– `visible_high` = is a point `+1.8` above this spot visible from the eye? → would a **standing** target here be seen

– `visible_low` = is a point `+0.8` above visible? → would a **crouching** target be seen

 

Both queries read the *same* depth texture; only the input world point differs by 1 m. Behind a ~1 m crate: the `+1.8` ray clears the crate (visible) but the `+0.8` ray is blocked → “standing seen, crouching hidden”.

Shader code
// Decal Shader
shader_type spatial;
render_mode cull_back, unshaded;

uniform sampler2D depth_texture : hint_depth_texture;
uniform sampler2D depth_camera_texture;            // single shadow map (from the eye)
uniform sampler2D normal_texture : hint_normal_roughness_texture;

uniform mat4 decal_inv_transform;
uniform mat4 depth_camera_view_matrix;
uniform mat4 depth_camera_projection_matrix;

uniform float fov_angle : hint_range(0.0, 0.5) = 0.25;
uniform int   stripe_count : hint_range(1, 500) = 100;
uniform float rotation : hint_range(-3.14, 3.14) = 0.0;
uniform float normal_fade : hint_range(0.0, 1.0) = 0.4;
uniform float full_radius : hint_range(0.0, 1.0, 0.01) = 0.5; // crawl-zone radius (solid)
uniform float offset_high = 1.8; // standing head height
uniform float offset_low  = 0.8; // crouching head height

uniform vec4 fov_color : source_color;
uniform vec4 decoy_color : source_color;
uniform vec4 hero_color : source_color = vec4(1.0);
uniform vec4 alert_color : source_color = vec4(1.0);

uniform bool is_detected = false;
uniform bool is_decoy = false;
uniform float fov_progress : hint_range(0.0, 1.0, 0.001) = 0.0;

// Project a world point into the shadow map and test if it's visible from the eye.
bool point_visible(vec3 wp, mat4 vp) {
	vec4 cs = vp * vec4(wp, 1.0);
	vec3 suv = cs.xyz / cs.w;
	suv = suv * 0.5 + 0.5;
	suv.y = 1.0 - suv.y;
	float sd = texture(depth_camera_texture, clamp(suv.xy, 0.0, 1.0)).r;
	return suv.z <= 1.0 - sd + 0.00001; // reverse-Z: 1.0 - sd = forward depth
}

void fragment() {
	float depth = texture(depth_texture, SCREEN_UV).x;
	vec3 ndc = vec3(SCREEN_UV * 2.0 - 1.0, depth);
	vec4 world = INV_VIEW_MATRIX * INV_PROJECTION_MATRIX * vec4(ndc, 1.0);
	vec3 world_position = world.xyz / world.w;

	mat4 vp = depth_camera_projection_matrix * depth_camera_view_matrix;
	vec4 local_pos = decal_inv_transform * vec4(world_position, 1.0);

	vec2 dir = local_pos.xz;
	float r = length(dir);
	float angle = atan(dir.y, dir.x);
	angle = mod(angle + rotation + PI * 0.5, 2.0 * PI);
	float normalized_angle = angle / (2.0 * PI);
	float half_fov = fov_angle * 0.5;

	float stripe = mod(floor(r * float(stripe_count)), 2.0);

	if (r > 0.5 || r < 0.02) discard;                                  // range
	if (normalized_angle > half_fov && normalized_angle < (1.0 - half_fov)) discard; // cone angle

	bool decal_area = true;

	vec3 normal = normalize(texture(normal_texture, SCREEN_UV).xyz * 2.0 - 1.0);
	if (clamp(normal.y, 0.0, 1.0) < normal_fade) discard;              // up-facing only

	// --- dual-height sampling: one shadow map, two head heights ---
	bool visible_high = point_visible(world_position + vec3(0.0, offset_high, 0.0), vp);
	bool visible_low  = point_visible(world_position + vec3(0.0, offset_low,  0.0), vp);
	bool crouch_caught = visible_low && (r <= full_radius / 2.0);

	vec4 warning_color = is_decoy ? decoy_color : hero_color;

	if (visible_high && decal_area) {
		float spread = step(r, fov_progress / 2.0);
		if (!crouch_caught) {                          // only standing seen -> stripes
			if (stripe >= 1.0 && r >= spread) discard;
		}
		ALBEDO = fov_color.rgb;
		ALPHA = fov_color.a;
		if (is_detected && r <= fov_progress / 2.0) {  // detection fill
			ALBEDO = warning_color.rgb;
			ALPHA = warning_color.a;
		}
	} else if (decal_area) {                           // blocked even standing
		ALBEDO = vec3(0.0);
		ALPHA = 0.2;
	} else {
		discard;
	}
}

//Compositor Effect
@tool
extends CompositorEffect
class_name DepthBuffer

var output_texture:RID
@export var texture_size:Vector2 = Vector2(1024,1024)

func _init() -> void:
	# print("DepthBuffer: Initializing...")  # Debug info
	# 使用 PRE_OPAQUE 回调
	effect_callback_type = CompositorEffect.EFFECT_CALLBACK_TYPE_PRE_OPAQUE
	# print("DepthBuffer: Effect callback type set to: ", effect_callback_type)  # Debug info

func _notification(what:int) -> void:
	if what == NOTIFICATION_PREDELETE:
		# print("DepthBuffer: Cleaning up...")  # Debug info
		output_texture = RID()
		pipeline = RID()
		shader = RID()
		nearest_sampler = RID()
		initialized_texture_size = Vector2i.ZERO

var rd : RenderingDevice
var nearest_sampler: RID
var shader : RID
var pipeline : RID
var initialized_texture_size := Vector2i.ZERO

func _free_compute_resources() -> void:
	if not rd or not is_instance_valid(rd):
		output_texture = RID()
		pipeline = RID()
		shader = RID()
		nearest_sampler = RID()
		initialized_texture_size = Vector2i.ZERO
		return
	if output_texture.is_valid():
		rd.free_rid(output_texture)
	if pipeline.is_valid():
		rd.free_rid(pipeline)
	if shader.is_valid():
		rd.free_rid(shader)
	if nearest_sampler.is_valid():
		rd.free_rid(nearest_sampler)
	output_texture = RID()
	pipeline = RID()
	shader = RID()
	nearest_sampler = RID()
	initialized_texture_size = Vector2i.ZERO

func _initialize_compute()->void:
	# print("DepthBuffer: Initializing compute...")  # Debug info
	rd = RenderingServer.get_rendering_device()
	
	if !rd:
		# print("DepthBuffer: No rendering device!")  # Debug info
		return

	# Create output texture
	var size := Vector2i(texture_size)
	var format := RDTextureFormat.new()
	format.width = size.x
	format.height = size.y
	format.format = RenderingDevice.DATA_FORMAT_R32_SFLOAT
	format.usage_bits = RenderingDevice.TEXTURE_USAGE_STORAGE_BIT | RenderingDevice.TEXTURE_USAGE_SAMPLING_BIT
	output_texture = rd.texture_create(format, RDTextureView.new(), [])
	initialized_texture_size = size
	# print("DepthBuffer: Created output texture")  # Debug info

	var sampler_state: RDSamplerState = RDSamplerState.new()
	sampler_state.min_filter = RenderingDevice.SAMPLER_FILTER_NEAREST
	sampler_state.mag_filter = RenderingDevice.SAMPLER_FILTER_NEAREST
	sampler_state.repeat_u = RenderingDevice.SAMPLER_REPEAT_MODE_CLAMP_TO_EDGE
	sampler_state.repeat_v = RenderingDevice.SAMPLER_REPEAT_MODE_CLAMP_TO_EDGE
	nearest_sampler = rd.sampler_create(sampler_state)
	# print("DepthBuffer: Created sampler")  # Debug info

	var shader_file: RDShaderFile = load("res://RenderingEffects/DepthBuffer/depth_buffer.glsl")
	var shader_spirv: RDShaderSPIRV = shader_file.get_spirv()
	shader = rd.shader_create_from_spirv(shader_spirv)
	pipeline = rd.compute_pipeline_create(shader)
	# print("DepthBuffer: Created shader and pipeline")  # Debug info

func _render_callback(p_effect_callback_type:int, p_render_data: RenderData)->void:	
	# print("DepthBuffer: Render callback called with type: ", p_effect_callback_type)  # Debug info
	
	if p_effect_callback_type != CompositorEffect.EFFECT_CALLBACK_TYPE_PRE_OPAQUE:
		return

	var requested_texture_size := Vector2i(texture_size)
	if requested_texture_size != initialized_texture_size:
		_free_compute_resources()
		_initialize_compute()

	if not output_texture.is_valid():		
		# print("DepthBuffer: Output texture is invalid!")  # Debug info
		return
		
	if rd:
		var render_scene_buffers : RenderSceneBuffersRD = p_render_data.get_render_scene_buffers()
		if render_scene_buffers:
			var size:Vector2i = initialized_texture_size
			# print("DepthBuffer: Using texture size: ", size)  # Debug info

			var x_groups:int = (size.x - 1) / 16 + 1
			var y_groups:int = (size.y - 1) / 16 + 1

			var view_count:int = render_scene_buffers.get_view_count()
			# print("DepthBuffer: View count: ", view_count)  # Debug info
			
			for view in range(view_count):
				var depth_tex: RID = render_scene_buffers.get_depth_layer(view)
				if not depth_tex.is_valid():
					# print("DepthBuffer: Invalid depth texture for view ", view)  # Debug info
					continue
					
				# print("DepthBuffer: Processing depth texture for view ", view)  # Debug info
			
				var depth_uniform : RDUniform = RDUniform.new()
				depth_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_SAMPLER_WITH_TEXTURE
				depth_uniform.binding = 0
				depth_uniform.add_id(nearest_sampler)
				depth_uniform.add_id(depth_tex)

				var depth_copy_uniform : RDUniform = RDUniform.new()
				depth_copy_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_IMAGE
				depth_copy_uniform.binding = 1
				depth_copy_uniform.add_id(output_texture)

				var params: PackedFloat32Array = PackedFloat32Array()
				params.push_back(size.x)
				params.push_back(size.y)
				var params_buffer: RID = rd.storage_buffer_create(params.size()* 4, params.to_byte_array())

				var params_uniform : RDUniform = RDUniform.new()
				params_uniform.uniform_type = RenderingDevice.UNIFORM_TYPE_STORAGE_BUFFER
				params_uniform.binding = 2
				params_uniform.add_id(params_buffer)

				var uniform_set: RID = rd.uniform_set_create([depth_uniform, depth_copy_uniform, params_uniform],shader,0)
		
				var compute_list := rd.compute_list_begin()
				rd.compute_list_bind_compute_pipeline(compute_list, pipeline)
				rd.compute_list_bind_uniform_set(compute_list, uniform_set, 0)
				rd.compute_list_dispatch(compute_list, x_groups, y_groups, 1)
				rd.compute_list_end()

				rd.free_rid(uniform_set)
				rd.free_rid(params_buffer)
		else:
			# print("DepthBuffer: No render scene buffers!")  # Debug info
			pass
	else:
		# print("DepthBuffer: Invalid render device or wrong callback type!")  # Debug info
		pass


#Compute Shader
#[compute]
#version 450

layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in;

layout(set = 0, binding = 0) uniform sampler2D depth_buffer;
layout(r32f, set = 0, binding = 1) uniform restrict writeonly image2D depth_copy;

layout(set = 0, binding = 2, std430) restrict buffer Params {
    vec2 resolution;
} params;

void main() {
    ivec2 xy = ivec2(gl_GlobalInvocationID.xy);
    ivec2 size = ivec2(params.resolution);
    if (xy.x >= size.x || xy.y >= size.y) {
        return;
    }
    
    float depth = texelFetch(depth_buffer, xy, 0).r;
    
    imageStore(depth_copy, xy, vec4(depth, 0.0, 0.0, 0.0));
}
Live Preview
Tags
CompositorEffect, FOV, Shadow Mapping
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.

More from JasonKim

Related shaders

guest

0 Comments
Oldest
Newest Most Voted