Dynamic shadows demo mod

This commit is contained in:
Luke Street
2026-07-10 01:08:49 -06:00
parent 409b60836b
commit 84e7394d5b
6 changed files with 1389 additions and 0 deletions
+1
View File
@@ -561,6 +561,7 @@ if (DUSK_ENABLE_CODE_MODS AND CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR
add_custom_target(dusklight_mods) # Aggregate target for all in-tree mods
add_subdirectory(mods/template_mod)
add_subdirectory(mods/ao_mod)
add_subdirectory(mods/shadow_mod)
endif ()
if (APPLE)
@@ -4,6 +4,7 @@
#include "JSystem/J3DGraphBase/J3DShapeDraw.h"
#include "JSystem/J3DAssert.h"
#include "JSystem/J3DGraphBase/J3DFifo.h"
#include "JSystem/JMath/JMath.h"
#include <mtx.h>
#include "dusk/endian_gx.hpp"
+20
View File
@@ -0,0 +1,20 @@
cmake_minimum_required(VERSION 3.25)
project(shadow_mod CXX)
if (CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
set(DUSK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to dusk source root")
option(DUSK_MOD_USE_FULL_TREE "Use full build instead of the minimal mod SDK" OFF)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
if (DUSK_MOD_USE_FULL_TREE)
add_subdirectory("${DUSK_DIR}" dusk EXCLUDE_FROM_ALL)
else ()
add_subdirectory("${DUSK_DIR}/sdk" dusk-sdk EXCLUDE_FROM_ALL)
endif ()
endif ()
add_mod(shadow_mod
SOURCES src/mod.cpp
MOD_JSON mod.json
RES_DIR res
BUNDLE
)
+7
View File
@@ -0,0 +1,7 @@
{
"id": "dev.twilitrealm.shadow_mod",
"name": "[Demo] Dynamic Shadows",
"version": "1.0.0",
"author": "encounter",
"description": "Demo showcasing dynamic shadow maps: re-renders geometry from the sun (or moon) point of view and composites real-time shadows over the world, with screen-space contact shadows for fine detail."
}
+262
View File
@@ -0,0 +1,262 @@
// Deferred shadow composite: reconstructs the world position of every scene pixel from the
// depth snapshot (CameraService matrices), transforms it into the light's clip space, and
// PCF-compares against the shadow map rendered earlier this frame. Drawn as a fullscreen
// triangle with multiply blending (srcFactor = Dst, dstFactor = Zero) before the HUD.
//
// Depth conventions (both reversed-Z): the scene snapshot has 1.0 at the camera near plane;
// the shadow map, rendered through the game's GX pipeline with a GC-convention light matrix,
// stores clip.z, i.e. 1.0 nearest to the light and 0.0 at the light frustum far plane.
//
// The optional contact-shadow raymarch follows Panos Karabelas' screen-space shadows
// (https://panoskarabelas.com/blog/posts/screen_space_shadows/, MIT via Spartan Engine):
// march from the pixel toward the light in view space and mark occlusion when the ray dips
// behind the depth buffer within a thickness threshold.
struct Uniforms {
world_from_proj: mat4x4f, // scene depth unproject (camera)
view_from_proj: mat4x4f, // scene depth -> view space (contact shadows)
proj_from_view: mat4x4f, // view -> clip (contact shadows re-projection)
light_vp: mat4x4f, // world -> light receiver projection (UV/depth basis)
light_dir_view: vec3f, // direction *toward* the light, view space, normalized
bias: f32, // shadow-map depth bias (reversed-depth units)
size: vec2f, // shadow map size in texels
inv_size: vec2f,
strength: f32, // final darkening amount, horizon fade baked in
pcf_taps: f32, // 0 = single tap, 1 = 3x3, 2 = 5x5
contact_enabled: f32,
contact_thickness: f32, // view-space thickness threshold
contact_length: f32, // view-space march distance
debug_mode: u32, // 0 = composite; nonzero modes are diagnostic views
_pad0: f32,
_pad1: f32,
}
@group(0) @binding(0) var scene_depth: texture_2d<f32>;
@group(0) @binding(1) var shadow_map: texture_2d<f32>;
@group(0) @binding(2) var<uniform> uniforms: Uniforms;
@group(0) @binding(3) var light_color: texture_2d<f32>;
struct VertexOutput {
@builtin(position) position: vec4f,
@location(0) uv: vec2f,
}
@vertex
fn vs_main(@builtin(vertex_index) index: u32) -> VertexOutput {
var out: VertexOutput;
let uv = vec2f(f32((index << 1u) & 2u), f32(index & 2u));
out.position = vec4f(uv * vec2f(2.0, -2.0) + vec2f(-1.0, 1.0), 0.0, 1.0);
out.uv = uv;
return out;
}
fn load_shadow(texel: vec2<i32>) -> f32 {
let clamped = clamp(texel, vec2<i32>(0i), vec2<i32>(uniforms.size) - 1i);
return textureLoad(shadow_map, clamped, 0i).r;
}
// Returns 1.0 when the pixel at light-space depth `receiver` is shadowed by the map texel.
fn shadow_test(texel: vec2<i32>, receiver: f32) -> f32 {
// Reversed depth: a larger stored value is closer to the light, i.e. an occluder.
return select(0.0, 1.0, load_shadow(texel) > receiver + uniforms.bias);
}
// Bilinearly weighted comparison (what a hardware comparison sampler would do): filter the
// four *comparison results*, never the depths themselves. This is what turns per-texel
// staircases into smooth penumbra edges.
fn shadow_compare_bilinear(light_uv: vec2f, receiver: f32) -> f32 {
let coordinates = light_uv * uniforms.size - 0.5;
let base = floor(coordinates);
let fraction = coordinates - base;
let texel = vec2<i32>(base);
let s00 = shadow_test(texel, receiver);
let s10 = shadow_test(texel + vec2<i32>(1i, 0i), receiver);
let s01 = shadow_test(texel + vec2<i32>(0i, 1i), receiver);
let s11 = shadow_test(texel + vec2<i32>(1i, 1i), receiver);
let top = mix(s00, s10, fraction.x);
let bottom = mix(s01, s11, fraction.x);
return mix(top, bottom, fraction.y);
}
fn sample_shadow_pcf(light_uv: vec2f, receiver: f32) -> f32 {
let radius = i32(uniforms.pcf_taps);
var sum = 0.0;
var count = 0.0;
for (var y = -radius; y <= radius; y += 1i) {
for (var x = -radius; x <= radius; x += 1i) {
let offset = vec2f(f32(x), f32(y)) * uniforms.inv_size;
sum += shadow_compare_bilinear(light_uv + offset, receiver);
count += 1.0;
}
}
return sum / count;
}
fn scene_depth_at(uv: vec2f) -> f32 {
let size = vec2<i32>(textureDimensions(scene_depth));
let texel = clamp(vec2<i32>(uv * vec2f(size)), vec2<i32>(0i), size - 1i);
return textureLoad(scene_depth, texel, 0i).r;
}
fn light_color_at(uv: vec2f) -> vec4f {
let size = vec2<i32>(textureDimensions(light_color));
let texel = clamp(vec2<i32>(uv * vec2f(size)), vec2<i32>(0i), size - 1i);
return textureLoad(light_color, texel, 0i);
}
fn light_depth_debug_at(uv: vec2f) -> vec3f {
let texel = vec2<i32>(uv * uniforms.size);
let depth = load_shadow(texel);
if depth <= 0.0 {
return vec3f(0.0);
}
let dx = abs(depth - load_shadow(texel + vec2<i32>(1i, 0i)));
let dy = abs(depth - load_shadow(texel + vec2<i32>(0i, 1i)));
let edge = saturate((dx + dy) * 500.0);
let shade = saturate(depth * 1.5);
let bands = 0.08 * (0.5 + 0.5 * cos(depth * 96.0));
return vec3f(saturate(shade + bands + edge));
}
fn view_position(uv: vec2f, depth: f32) -> vec3f {
let ndc = vec4f(uv.x * 2.0 - 1.0, 1.0 - 2.0 * uv.y, depth, 1.0);
let position = uniforms.view_from_proj * ndc;
return position.xyz / position.w;
}
// Interleaved gradient noise (Jimenez); fixed per-pixel dither, no temporal rotation.
fn ign(pixel: vec2f) -> f32 {
return fract(52.9829189 * fract(dot(pixel, vec2f(0.06711056, 0.00583715))));
}
// Screen-space contact shadows: march toward the light in view space; occluded when the ray
// passes behind the depth buffer by less than the thickness threshold. Faded out with view
// distance: position reconstruction error grows with distance while the thickness threshold
// is fixed, so far surfaces (and anything translucent composited over them - clouds, fog)
// would otherwise pick up dithered false occlusion. Contact shadows are a near-field effect.
fn contact_shadow_fade(view_distance: f32) -> f32 {
return saturate(1.0 - (view_distance - 3000.0) / 5000.0);
}
fn contact_shadow(origin: vec3f, pixel: vec2f) -> f32 {
let steps = 24;
let step_vec = uniforms.light_dir_view * (uniforms.contact_length / f32(steps));
var ray = origin + step_vec * ign(pixel);
for (var i = 0; i < steps; i += 1) {
ray += step_vec;
// Project the ray position back to screen space.
let clip = uniforms.proj_from_view * vec4f(ray, 1.0);
if clip.w <= 0.0 {
break;
}
let ray_ndc = clip.xyz / clip.w;
let ray_uv = vec2f(0.5 + 0.5 * ray_ndc.x, 0.5 - 0.5 * ray_ndc.y);
if any(ray_uv < vec2f(0.0)) || any(ray_uv > vec2f(1.0)) {
break;
}
let scene = scene_depth_at(ray_uv);
if scene <= 0.0 {
continue;
}
// Compare in view space: positive delta = the ray is behind the scene surface.
let scene_z = view_position(ray_uv, scene).z;
let delta = scene_z - ray.z; // view space looks down -z; larger z = closer
if delta > 0.0 && delta < uniforms.contact_thickness {
return 1.0;
}
}
return 0.0;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4f {
let depth = scene_depth_at(in.uv);
if uniforms.debug_mode == 1u {
let value = load_shadow(vec2<i32>(in.uv * uniforms.size));
return vec4f(value, value, value, 1.0);
}
if uniforms.debug_mode == 9u || uniforms.debug_mode == 10u {
let color = light_color_at(in.uv);
let color_luma = max(color.r, max(color.g, color.b));
let depth_color = light_depth_debug_at(in.uv);
let rgb = select(depth_color, color.rgb, color_luma > (1.0 / 255.0));
return vec4f(rgb, 1.0);
}
if depth <= 0.0 {
// Sky / cleared pixels receive no shadow.
if uniforms.debug_mode >= 3u {
return vec4f(0.0, 0.0, 0.0, 1.0);
}
return vec4f(1.0);
}
let ndc = vec4f(in.uv.x * 2.0 - 1.0, 1.0 - 2.0 * in.uv.y, depth, 1.0);
let world4 = uniforms.world_from_proj * ndc;
let world = world4.xyz / world4.w;
let light_clip = uniforms.light_vp * vec4f(world, 1.0);
let light_ndc = light_clip.xyz / light_clip.w;
let receiver = light_ndc.z; // reversed light depth, 1 = nearest to the light
let light_uv = vec2f(0.5 + 0.5 * light_ndc.x, 0.5 - 0.5 * light_ndc.y);
let in_shadow_bounds = all(light_uv >= vec2f(0.0)) && all(light_uv <= vec2f(1.0)) &&
receiver > 0.0 && receiver <= 1.0;
let shadow_depth = load_shadow(vec2<i32>(light_uv * uniforms.size));
if uniforms.debug_mode == 4u {
let valid = select(0.0, 1.0, in_shadow_bounds);
return vec4f(saturate(light_uv.x), saturate(light_uv.y), valid, 1.0);
}
if uniforms.debug_mode == 5u {
if !in_shadow_bounds {
return vec4f(0.0, 0.0, 0.0, 1.0);
}
let current_compare = select(0.0, 1.0, shadow_depth > receiver + uniforms.bias);
let opposite_compare = select(0.0, 1.0, shadow_depth < receiver - uniforms.bias);
return vec4f(current_compare, 0.0, opposite_compare, 1.0);
}
if uniforms.debug_mode == 6u {
let valid = select(0.0, 1.0, in_shadow_bounds);
return vec4f(saturate(receiver), shadow_depth, valid, 1.0);
}
if uniforms.debug_mode == 7u {
let beyond_far = select(0.0, 1.0, receiver <= 0.0);
let valid_depth = select(0.0, 1.0, receiver > 0.0 && receiver <= 1.0);
let before_near = select(0.0, 1.0, receiver > 1.0);
return vec4f(beyond_far, valid_depth, before_near, 1.0);
}
if uniforms.debug_mode == 8u {
let valid_x = select(0.0, 1.0, light_uv.x >= 0.0 && light_uv.x <= 1.0);
let valid_y = select(0.0, 1.0, light_uv.y >= 0.0 && light_uv.y <= 1.0);
let valid_depth = select(0.0, 1.0, receiver > 0.0 && receiver <= 1.0);
return vec4f(valid_x, valid_y, valid_depth, 1.0);
}
var occlusion = 0.0;
if in_shadow_bounds {
occlusion = sample_shadow_pcf(light_uv, receiver);
}
if uniforms.debug_mode == 3u {
return vec4f(occlusion, occlusion, occlusion, 1.0);
}
if uniforms.contact_enabled != 0.0 && occlusion < 1.0 {
let origin = view_position(in.uv, depth);
let fade = contact_shadow_fade(-origin.z);
if fade > 0.0 {
occlusion = max(occlusion, fade * contact_shadow(origin, in.position.xy));
}
}
let value = 1.0 - uniforms.strength * occlusion;
if uniforms.debug_mode == 2u {
return vec4f(value, value, value, 1.0);
}
return vec4f(value, value, value, 1.0);
}
File diff suppressed because it is too large Load Diff