【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));
}
