diff --git a/CMakeLists.txt b/CMakeLists.txt index abf30f6863..2af48dbcb0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h b/libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h index 5477868596..28cb9f7fe6 100644 --- a/libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h +++ b/libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h @@ -4,6 +4,7 @@ #include "JSystem/J3DGraphBase/J3DShapeDraw.h" #include "JSystem/J3DAssert.h" #include "JSystem/J3DGraphBase/J3DFifo.h" +#include "JSystem/JMath/JMath.h" #include #include "dusk/endian_gx.hpp" diff --git a/mods/shadow_mod/CMakeLists.txt b/mods/shadow_mod/CMakeLists.txt new file mode 100644 index 0000000000..4479d0fcc4 --- /dev/null +++ b/mods/shadow_mod/CMakeLists.txt @@ -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 +) diff --git a/mods/shadow_mod/mod.json b/mods/shadow_mod/mod.json new file mode 100644 index 0000000000..09fe8a3a84 --- /dev/null +++ b/mods/shadow_mod/mod.json @@ -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." +} diff --git a/mods/shadow_mod/res/shadow.wgsl b/mods/shadow_mod/res/shadow.wgsl new file mode 100644 index 0000000000..83065b1257 --- /dev/null +++ b/mods/shadow_mod/res/shadow.wgsl @@ -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; +@group(0) @binding(1) var shadow_map: texture_2d; +@group(0) @binding(2) var uniforms: Uniforms; +@group(0) @binding(3) var light_color: texture_2d; + +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) -> f32 { + let clamped = clamp(texel, vec2(0i), vec2(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, 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(base); + let s00 = shadow_test(texel, receiver); + let s10 = shadow_test(texel + vec2(1i, 0i), receiver); + let s01 = shadow_test(texel + vec2(0i, 1i), receiver); + let s11 = shadow_test(texel + vec2(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(textureDimensions(scene_depth)); + let texel = clamp(vec2(uv * vec2f(size)), vec2(0i), size - 1i); + return textureLoad(scene_depth, texel, 0i).r; +} + +fn light_color_at(uv: vec2f) -> vec4f { + let size = vec2(textureDimensions(light_color)); + let texel = clamp(vec2(uv * vec2f(size)), vec2(0i), size - 1i); + return textureLoad(light_color, texel, 0i); +} + +fn light_depth_debug_at(uv: vec2f) -> vec3f { + let texel = vec2(uv * uniforms.size); + let depth = load_shadow(texel); + if depth <= 0.0 { + return vec3f(0.0); + } + + let dx = abs(depth - load_shadow(texel + vec2(1i, 0i))); + let dy = abs(depth - load_shadow(texel + vec2(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(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(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); +} diff --git a/mods/shadow_mod/src/mod.cpp b/mods/shadow_mod/src/mod.cpp new file mode 100644 index 0000000000..df57eadcbd --- /dev/null +++ b/mods/shadow_mod/src/mod.cpp @@ -0,0 +1,1098 @@ +// Dynamic shadows example mod. +// +// Replays the game's populated opaque scene draw lists into an offscreen pass with a light-space +// projection to produce a shadow map of the live scene, then composites deferred shadows over the +// world (scene depth + CameraService unproject + PCF against the map). +// +// The optional contact-shadow raymarch in the composite is reimplemented from Panos Karabelas' +// screen-space shadows (MIT, via Spartan Engine); see res/shadow.wgsl. + +#include "global.h" + +#include "JSystem/J3DGraphBase/J3DShape.h" +#include "JSystem/J3DU/J3DUClipper.h" +#include "JSystem/JMath/JMath.h" +#include "d/d_com_inf_game.h" +#include "d/d_kankyo.h" +#include "d/d_kankyo_rain.h" +#include "dolphin/gx/GXAurora.h" +#include "dolphin/gx/GXGet.h" +#include "dolphin/gx/GXPixel.h" +#include "dolphin/gx/GXTransform.h" +#include "m_Do/m_Do_mtx.h" +#include "mods/hook.hpp" +#include "mods/service.hpp" +#include "mods/svc/camera.h" +#include "mods/svc/config.h" +#include "mods/svc/game.h" +#include "mods/svc/gfx.h" +#include "mods/svc/hook.h" +#include "mods/svc/log.h" +#include "mods/svc/resource.h" +#include "mods/svc/ui.h" + +#include +#include +#include +#include +#include +#include + +DEFINE_MOD(); +IMPORT_SERVICE(ConfigService, svc_config); +IMPORT_SERVICE(ResourceService, svc_resource); +IMPORT_SERVICE(UiService, svc_ui); +IMPORT_SERVICE(GfxService, svc_gfx); +IMPORT_SERVICE(CameraService, svc_camera); +IMPORT_SERVICE(HookService, svc_hook); +IMPORT_SERVICE(GameService, svc_game); +IMPORT_SERVICE(LogService, svc_log); + +namespace { + +ConfigVarHandle g_cvarEnabled = 0; +ConfigVarHandle g_cvarMapSize = 0; +ConfigVarHandle g_cvarNoFrustumClipping = 0; +ConfigVarHandle g_cvarStrength = 0; +ConfigVarHandle g_cvarPcf = 0; +ConfigVarHandle g_cvarBias = 0; +ConfigVarHandle g_cvarBoxRadius = 0; +ConfigVarHandle g_cvarContactShadows = 0; +ConfigVarHandle g_cvarDebugView = 0; + +GfxDrawTypeHandle g_drawType = 0; +GfxStageHookHandle g_sceneBeginHook = 0; +GfxStageHookHandle g_sceneAfterTerrainHook = 0; +GfxStageHookHandle g_frameBeforeHudHook = 0; +UiWindowHandle g_controlsWindow = 0; +ResourceBuffer g_shaderSource = RESOURCE_BUFFER_INIT; +GfxDeviceInfo g_deviceInfo = GFX_DEVICE_INFO_INIT; +WGPURenderPipeline g_compositePipeline = nullptr; // multiply blend +WGPURenderPipeline g_compositeDebugPipeline = nullptr; // no blend (debug views) +WGPUBindGroupLayout g_compositeLayout = nullptr; +WGPUBindGroupLayout g_compositeDebugLayout = nullptr; + +struct MapPassOutput { + bool ready = false; + WGPUTextureView shadowMap = nullptr; // frame-pooled + WGPUTextureView lightColor = nullptr; // frame-pooled + uint32_t mapSize = 0; + Mtx44 lightVp; // world -> light receiver projection, row-major game convention + float dirToLightWorld[3]; // toward the light, normalized + float fade = 0.0f; +}; + +MapPassOutput g_mapPass; +bool g_replayingSceneLists = false; + +constexpr float kLightDistance = 30000.0f; +constexpr float kLightNear = 100.0f; +constexpr float kLightFar = 60000.0f; +constexpr float kMaxLightLookahead = 10000.0f; +constexpr float kSunMoonDistance = 80000.0f; +constexpr float kSunMoonZDistance = -48000.0f; + +using ClipperSphereClip = int (J3DUClipper::*)(f32 const (*)[4], Vec, f32) const; +using ClipperBoxClip = int (J3DUClipper::*)(f32 const (*)[4], Vec*, Vec*) const; +constexpr ClipperSphereClip kClipperSphereClip = static_cast(&J3DUClipper::clip); +constexpr ClipperBoxClip kClipperBoxClip = static_cast(&J3DUClipper::clip); + +// Mirror of the WGSL Uniforms struct (keep in sync with res/shadow.wgsl). +struct ShadowUniforms { + float world_from_proj[16]; + float view_from_proj[16]; + float proj_from_view[16]; + float light_vp[16]; + float light_dir_view[3]; + float bias; + float size[2]; + float inv_size[2]; + float strength; + float pcf_taps; + float contact_enabled; + float contact_thickness; + float contact_length; + uint32_t debug_mode; + float _pad0; + float _pad1; +}; +static_assert(sizeof(ShadowUniforms) % 16 == 0); + +struct DrawPayload { + WGPUTextureView sceneDepth; // frame-pooled + WGPUTextureView shadowMap; // frame-pooled + WGPUTextureView lightColor; // frame-pooled + uint32_t uniform_offset; + uint32_t uniform_size; + uint32_t debug_mode; +}; +static_assert(sizeof(DrawPayload) <= GFX_INLINE_DRAW_PAYLOAD_SIZE); +static_assert(std::is_trivially_copyable_v); + +struct LightCamera { + Mtx view; + Mtx44 ortho; + Mtx44 vp; + float dirToLight[3]; + float fade = 0.0f; +}; + +struct SceneCamera { + bool valid = false; + bool raw_valid = false; + CameraInfo info = CAMERA_INFO_INIT; + Mtx raw_view; + f32 raw_projection[7]{}; + Mtx44 raw_projection_mtx; +}; + +SceneCamera g_sceneCamera; + +struct ActualLightDebugState { + bool active = false; + Mtx savedView; + f32 savedProjection[7]; + f32 savedViewport[6]; + u32 savedScissor[4]; +}; + +ActualLightDebugState g_actualLightDebug; + +struct replay_scope { + replay_scope() { g_replayingSceneLists = true; } + ~replay_scope() { g_replayingSceneLists = false; } +}; + +int64_t get_int_option(ConfigVarHandle handle, int64_t fallback) { + int64_t value = fallback; + if (handle == 0 || svc_config->get_int(mod_ctx, handle, &value) != MOD_OK) { + return fallback; + } + return value; +} + +bool get_bool_option(ConfigVarHandle handle, bool fallback) { + bool value = fallback; + if (handle == 0 || svc_config->get_bool(mod_ctx, handle, &value) != MOD_OK) { + return fallback; + } + return value; +} + +int64_t get_debug_mode() { + return std::clamp(get_int_option(g_cvarDebugView, 0), 0, 10); +} + +bool matrix_ready(const Mtx m) { + float basis = 0.0f; + for (int r = 0; r < 3; ++r) { + for (int c = 0; c < 4; ++c) { + if (!std::isfinite(m[r][c])) { + return false; + } + if (c < 3) { + basis += std::fabs(m[r][c]); + } + } + } + return basis > 0.001f; +} + +bool projection_vector_ready(const f32 projection[7]) { + if (projection[0] != 0.0f) { + return false; + } + for (int i = 1; i < 7; ++i) { + if (!std::isfinite(projection[i])) { + return false; + } + } + return std::fabs(projection[1]) > 0.001f && std::fabs(projection[3]) > 0.001f && + std::fabs(projection[6]) > 0.001f; +} + +// Row-major game matrix -> column-major WGSL layout (matching CameraService). +void store_column_major(const Mtx44 in, float out[16]) { + for (int c = 0; c < 4; ++c) { + for (int r = 0; r < 4; ++r) { + out[c * 4 + r] = in[r][c]; + } + } +} + +void copy_projection(const Mtx44 in, Mtx44 out) { + std::memcpy(out, in, sizeof(Mtx44)); + // TODO: check GfxDeviceInfo.uses_reversed_z + for (int c = 0; c < 4; ++c) { + out[2][c] = -out[2][c]; + } +} + +void projection_vector_from_perspective(const Mtx44 projection, f32 out[7]) { + out[0] = 0.0f; + out[1] = projection[0][0]; + out[2] = projection[0][2]; + out[3] = projection[1][1]; + out[4] = projection[1][2]; + out[5] = projection[2][2]; + out[6] = projection[2][3]; +} + +const view_class* stage_game_view(const GfxStageContext* stageCtx) { + if (stageCtx == nullptr || stageCtx->struct_size < sizeof(GfxStageContext) || + stageCtx->game_view == nullptr) + { + return nullptr; + } + return static_cast(stageCtx->game_view); +} + +bool capture_raw_camera( + const view_class* gameView, Mtx outView, Mtx44 outProjectionMtx, f32 outProjection[7]) { + if (gameView == nullptr || !matrix_ready(gameView->viewMtx)) { + return false; + } + std::memcpy(outProjectionMtx, gameView->projMtx, sizeof(Mtx44)); + projection_vector_from_perspective(outProjectionMtx, outProjection); + if (!projection_vector_ready(outProjection)) { + return false; + } + cMtx_copy(gameView->viewMtx, outView); + return true; +} + +bool capture_scene_camera(const GfxStageContext* stageCtx) { + g_sceneCamera.valid = false; + g_sceneCamera.raw_valid = false; + const view_class* gameView = stage_game_view(stageCtx); + if (gameView == nullptr) { + return false; + } + CameraInfo info = CAMERA_INFO_INIT; + if (svc_camera->get_camera(mod_ctx, stageCtx->game_view, &info) != MOD_OK) { + return false; + } + g_sceneCamera.info = info; + g_sceneCamera.valid = true; + g_sceneCamera.raw_valid = capture_raw_camera(gameView, g_sceneCamera.raw_view, + g_sceneCamera.raw_projection_mtx, g_sceneCamera.raw_projection); + return true; +} + +bool get_replay_camera(Mtx outView, Mtx44 outProjectionMtx, f32 outProjection[7]) { + if (g_sceneCamera.raw_valid && matrix_ready(g_sceneCamera.raw_view)) { + cMtx_copy(g_sceneCamera.raw_view, outView); + std::memcpy(outProjectionMtx, g_sceneCamera.raw_projection_mtx, + sizeof(g_sceneCamera.raw_projection_mtx)); + std::memcpy( + outProjection, g_sceneCamera.raw_projection, sizeof(g_sceneCamera.raw_projection)); + return projection_vector_ready(outProjection); + } + + return false; +} + +float wrap_daytime(float daytime) { + if (!std::isfinite(daytime)) { + return 180.0f; + } + float wrapped = std::fmod(daytime, 360.0f); + if (wrapped < 0.0f) { + wrapped += 360.0f; + } + return wrapped; +} + +float daytime_percent(float max, float min, float value) { + const float range = max - min; + if (range == 0.0f) { + return 1.0f; + } + const float percent = 1.0f - ((max - value) / range); + return percent < 1.0f ? percent : 1.0f; +} + +float sun_moon_angle(float daytime) { + daytime = wrap_daytime(daytime); + if (daytime >= 90.0f && daytime <= 270.0f) { + return daytime_percent(270.0f, 90.0f, daytime) * 150.0f + 105.0f; + } + + float angle = daytime; + if (angle < 90.0f) { + angle += 360.0f; + } + + angle = daytime_percent(450.0f, 270.0f, angle) * 210.0f + 255.0f; + if (angle > 360.0f) { + angle -= 360.0f; + } + return angle; +} + +cXyz sun_moon_offset(float daytime) { + const float angle = DEG_TO_RAD(sun_moon_angle(daytime)); + const float angleSin = sinf(angle); + const float angleCos = cosf(angle); + return cXyz{ + angleSin * kSunMoonDistance, -angleCos * kSunMoonDistance, angleCos * kSunMoonZDistance}; +} + +bool build_composite_pipeline( + bool blend, WGPURenderPipeline& outPipeline, WGPUBindGroupLayout& outLayout) { + WGPUShaderSourceWGSL wgsl = WGPU_SHADER_SOURCE_WGSL_INIT; + wgsl.code = {static_cast(g_shaderSource.data), g_shaderSource.size}; + WGPUShaderModuleDescriptor moduleDesc = WGPU_SHADER_MODULE_DESCRIPTOR_INIT; + moduleDesc.nextInChain = &wgsl.chain; + moduleDesc.label = {"shadow composite", WGPU_STRLEN}; + WGPUShaderModule module = wgpuDeviceCreateShaderModule(g_deviceInfo.device, &moduleDesc); + if (module == nullptr) { + return false; + } + + // Multiply blend: fragment output is the darkening multiplier (result = dst * src). + WGPUBlendState blendState{ + .color = {.operation = WGPUBlendOperation_Add, + .srcFactor = WGPUBlendFactor_Dst, + .dstFactor = WGPUBlendFactor_Zero}, + .alpha = {.operation = WGPUBlendOperation_Add, + .srcFactor = WGPUBlendFactor_Zero, + .dstFactor = WGPUBlendFactor_One}, + }; + WGPUColorTargetState colorTarget = WGPU_COLOR_TARGET_STATE_INIT; + colorTarget.format = g_deviceInfo.color_format; + if (blend) { + colorTarget.blend = &blendState; + } + WGPUFragmentState fragment = WGPU_FRAGMENT_STATE_INIT; + fragment.module = module; + fragment.entryPoint = {"fs_main", WGPU_STRLEN}; + fragment.targetCount = 1; + fragment.targets = &colorTarget; + WGPUDepthStencilState depthStencil = WGPU_DEPTH_STENCIL_STATE_INIT; + depthStencil.format = g_deviceInfo.depth_format; + depthStencil.depthWriteEnabled = WGPUOptionalBool_False; + depthStencil.depthCompare = WGPUCompareFunction_Always; + + WGPURenderPipelineDescriptor pipelineDesc = WGPU_RENDER_PIPELINE_DESCRIPTOR_INIT; + pipelineDesc.label = {blend ? "shadow composite" : "shadow composite (debug)", WGPU_STRLEN}; + pipelineDesc.vertex.module = module; + pipelineDesc.vertex.entryPoint = {"vs_main", WGPU_STRLEN}; + pipelineDesc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + pipelineDesc.depthStencil = &depthStencil; + pipelineDesc.multisample.count = g_deviceInfo.sample_count; + pipelineDesc.fragment = &fragment; + outPipeline = wgpuDeviceCreateRenderPipeline(g_deviceInfo.device, &pipelineDesc); + wgpuShaderModuleRelease(module); + if (outPipeline == nullptr) { + return false; + } + outLayout = wgpuRenderPipelineGetBindGroupLayout(outPipeline, 0); + return outLayout != nullptr; +} + +// Render worker thread: fullscreen deferred-shadow composite. +void on_draw( + ModContext*, const GfxDrawContext* ctx, const void* payload, size_t payloadSize, void*) { + if (payloadSize != sizeof(DrawPayload)) { + return; + } + DrawPayload data; + std::memcpy(&data, payload, sizeof(data)); + + WGPURenderPipeline pipeline = + data.debug_mode != 0 ? g_compositeDebugPipeline : g_compositePipeline; + WGPUBindGroupLayout layout = data.debug_mode != 0 ? g_compositeDebugLayout : g_compositeLayout; + if (data.sceneDepth == nullptr || data.shadowMap == nullptr || data.lightColor == nullptr || + pipeline == nullptr) + { + return; + } + + WGPUBindGroupEntry entries[4] = {WGPU_BIND_GROUP_ENTRY_INIT, WGPU_BIND_GROUP_ENTRY_INIT, + WGPU_BIND_GROUP_ENTRY_INIT, WGPU_BIND_GROUP_ENTRY_INIT}; + entries[0].binding = 0; + entries[0].textureView = data.sceneDepth; + entries[1].binding = 1; + entries[1].textureView = data.shadowMap; + entries[2].binding = 2; + entries[2].buffer = ctx->uniform_buffer; + entries[2].offset = data.uniform_offset; + entries[2].size = data.uniform_size; + entries[3].binding = 3; + entries[3].textureView = data.lightColor; + WGPUBindGroupDescriptor bindGroupDesc = WGPU_BIND_GROUP_DESCRIPTOR_INIT; + bindGroupDesc.layout = layout; + bindGroupDesc.entryCount = 4; + bindGroupDesc.entries = entries; + WGPUBindGroup bindGroup = wgpuDeviceCreateBindGroup(ctx->device, &bindGroupDesc); + if (bindGroup == nullptr) { + return; + } + + wgpuRenderPassEncoderSetPipeline(ctx->pass, pipeline); + wgpuRenderPassEncoderSetBindGroup(ctx->pass, 0, bindGroup, 0, nullptr); + wgpuRenderPassEncoderDraw(ctx->pass, 3, 1, 0, 0); + wgpuBindGroupRelease(bindGroup); +} + +// Picks the sun or moon (whichever is above the horizon) and returns the normalized +// world-space direction *toward* the light plus a horizon fade factor. False = no light. +bool compute_light(float outDirToLight[3], float& outFade) { + dScnKy_env_light_c* envLight = dKy_getEnvlight(); + if (envLight == nullptr) { + return false; + } + + // The packet positions can be stale when this runs before the world lists are consumed. + // Mirror dScnKy_env_light_c::setSunpos() so --time-of-day directly moves the debug light. + const float daytime = wrap_daytime(dComIfGs_getTime()); + cXyz offset = sun_moon_offset(daytime); + if (offset.y <= 0.0f) { + offset = sun_moon_offset(daytime + 180.0f); + } + const float length = std::sqrt(offset.x * offset.x + offset.y * offset.y + offset.z * offset.z); + if (length < 1.0f) { + return false; + } + outDirToLight[0] = offset.x / length; + outDirToLight[1] = offset.y / length; + outDirToLight[2] = offset.z / length; + // Fade shadows out as the light approaches the horizon (elevation below ~11 degrees). + outFade = std::clamp((outDirToLight[1] - 0.05f) / 0.15f, 0.0f, 1.0f); + return outFade > 0.0f; +} + +bool build_light_camera(const Mtx cameraView, uint32_t mapSize, float radius, LightCamera& out) { + Mtx cameraInvView; + cMtx_inverse(cameraView, cameraInvView); + if (!matrix_ready(cameraInvView)) { + return false; + } + if (!compute_light(out.dirToLight, out.fade)) { + return false; + } + + // Fit a fixed-radius ortho box around the visible play space. The camera target alone can sit + // behind the receiver field, while a far-horizon center drops foreground receivers. + const cXyz eye{cameraInvView[0][3], cameraInvView[1][3], cameraInvView[2][3]}; + cXyz forward{-cameraInvView[0][2], -cameraInvView[1][2], -cameraInvView[2][2]}; + const float forwardLength = + std::sqrt(forward.x * forward.x + forward.y * forward.y + forward.z * forward.z); + if (forwardLength > 0.001f) { + forward = forward / forwardLength; + } else { + forward = cXyz{0.0f, 0.0f, -1.0f}; + } + const float lookahead = std::min(radius * 0.75f, kMaxLightLookahead); + const cXyz center = eye + forward * lookahead; + const cXyz lightEye{center.x + out.dirToLight[0] * kLightDistance, + center.y + out.dirToLight[1] * kLightDistance, + center.z + out.dirToLight[2] * kLightDistance}; + const bool nearlyVertical = std::fabs(out.dirToLight[1]) > 0.99f; + cXyz up = nearlyVertical ? cXyz{0.0f, 0.0f, 1.0f} : cXyz{0.0f, 1.0f, 0.0f}; + + cMtx_lookAt(out.view, &lightEye, ¢er, &up, 0); + const float unitsPerTexel = (2.0f * radius) / static_cast(mapSize); + out.view[0][3] = std::round(out.view[0][3] / unitsPerTexel) * unitsPerTexel; + out.view[1][3] = std::round(out.view[1][3] / unitsPerTexel) * unitsPerTexel; + + C_MTXOrtho(out.ortho, radius, -radius, -radius, radius, kLightNear, kLightFar); + cMtx_concatProjView(out.ortho, out.view, out.vp); + return true; +} + +bool build_light_replay_projection( + const LightCamera& lightCamera, const Mtx cameraView, Mtx44 out) { + Mtx cameraInvView; + cMtx_inverse(cameraView, cameraInvView); + if (!matrix_ready(cameraInvView)) { + return false; + } + + Mtx lightFromCamera; + cMtx_concat(lightCamera.view, cameraInvView, lightFromCamera); + cMtx_concatProjView(lightCamera.ortho, lightFromCamera, out); + return true; +} + +// True when the dynamic shadow pass will run this frame: enabled, a camera exists, and the +// sun or moon is above the horizon. Also gates the game-shadow skip hooks, which fire earlier +// in the painter than our SCENE_AFTER_TERRAIN hook. +bool dynamic_shadows_wanted() { + if (!get_bool_option(g_cvarEnabled, true)) { + return false; + } + if (!g_sceneCamera.raw_valid) { + return false; + } + float dirToLight[3]; + float fade = 0.0f; + return compute_light(dirToLight, fade); +} + +HookAction on_game_shadow_pre(ModContext*, void*, void*, void*) { + if (!dynamic_shadows_wanted()) { + return HOOK_CONTINUE; + } + return HOOK_SKIP_ORIGINAL; +} + +HookAction on_frustum_clip_pre(ModContext*, void*, void* retval, void*) { + if (!get_bool_option(g_cvarNoFrustumClipping, false) || !dynamic_shadows_wanted()) { + return HOOK_CONTINUE; + } + if (retval != nullptr) { + *static_cast(retval) = 0; + } + return HOOK_SKIP_ORIGINAL; +} + +HookAction on_copy_tex_pre(ModContext*, void*, void*, void*) { + return g_replayingSceneLists ? HOOK_SKIP_ORIGINAL : HOOK_CONTINUE; +} + +void draw_opaque_scene_lists() { + dComIfGd_drawOpaListBG(); + dComIfGd_drawOpaListDarkBG(); + dComIfGd_drawOpaListMiddle(); + dComIfGd_drawOpaList(); + dComIfGd_drawOpaListDark(); + dComIfGd_drawOpaListPacket(); +} + +bool draw_lists_ready() { + return dComIfGd_getOpaListBG() != nullptr && dComIfGd_getOpaList() != nullptr && + dComIfGd_getOpaListDark() != nullptr && dComIfGd_getXluListBG() != nullptr && + dComIfGd_getListPacket() != nullptr; +} + +void render_shadow_map( + const Mtx replayView, const Mtx44 replayProjectionMtx, const f32 replayProjection[7]); + +void restore_actual_light_debug() { + if (!g_actualLightDebug.active) { + return; + } + + j3dSys.setViewMtx(g_actualLightDebug.savedView); + GXSetProjectionv(g_actualLightDebug.savedProjection); + GXSetViewport(g_actualLightDebug.savedViewport[0], g_actualLightDebug.savedViewport[1], + g_actualLightDebug.savedViewport[2], g_actualLightDebug.savedViewport[3], + g_actualLightDebug.savedViewport[4], g_actualLightDebug.savedViewport[5]); + GXSetScissor(g_actualLightDebug.savedScissor[0], g_actualLightDebug.savedScissor[1], + g_actualLightDebug.savedScissor[2], g_actualLightDebug.savedScissor[3]); + dKy_setLight(); + J3DShape::resetVcdVatCache(); + + g_actualLightDebug.active = false; +} + +void on_scene_begin(ModContext*, const GfxStageContext* stageCtx, void*) { + restore_actual_light_debug(); + capture_scene_camera(stageCtx); + if (!get_bool_option(g_cvarEnabled, true) || get_debug_mode() != 9) { + return; + } + + Mtx cameraView; + if (!g_sceneCamera.raw_valid || !matrix_ready(g_sceneCamera.raw_view)) { + return; + } + cMtx_copy(g_sceneCamera.raw_view, cameraView); + + const uint32_t mapSize = 1024u << std::clamp(get_int_option(g_cvarMapSize, 1), 0, 2); + const float radius = + static_cast(std::clamp(get_int_option(g_cvarBoxRadius, 6000), 1000, 20000)); + LightCamera lightCamera{}; + if (!build_light_camera(cameraView, mapSize, radius, lightCamera)) { + return; + } + Mtx44 lightProjection; + if (!build_light_replay_projection(lightCamera, cameraView, lightProjection)) { + return; + } + + cMtx_copy(cameraView, g_actualLightDebug.savedView); + GXGetProjectionv(g_actualLightDebug.savedProjection); + GXGetViewportv(g_actualLightDebug.savedViewport); + GXGetScissor(&g_actualLightDebug.savedScissor[0], &g_actualLightDebug.savedScissor[1], + &g_actualLightDebug.savedScissor[2], &g_actualLightDebug.savedScissor[3]); + g_actualLightDebug.active = true; + + j3dSys.setViewMtx(g_actualLightDebug.savedView); + GXSetProjectionFull(lightProjection); + dKy_setLight(); + J3DShape::resetVcdVatCache(); +} + +void on_scene_after_terrain(ModContext*, const GfxStageContext* stageCtx, void*) { + if (g_mapPass.ready) { + return; + } + + const view_class* gameView = stage_game_view(stageCtx); + Mtx replayView; + Mtx44 replayProjectionMtx; + f32 replayProjection[7]; + if (!capture_raw_camera(gameView, replayView, replayProjectionMtx, replayProjection)) { + return; + } + render_shadow_map(replayView, replayProjectionMtx, replayProjection); +} + +// Game thread, after the draw handlers have populated next frame's scene lists: replay opaque scene +// geometry from the light's point of view. +void render_shadow_map( + const Mtx replayView, const Mtx44 replayProjectionMtx, const f32 replayProjection[7]) { + if (g_mapPass.ready || !get_bool_option(g_cvarEnabled, true)) { + return; + } + const int64_t debugMode = get_debug_mode(); + if (debugMode == 9) { + return; + } + if (!matrix_ready(replayView)) { + return; + } + Mtx replayViewMtx; + cMtx_copy(replayView, replayViewMtx); + + const uint32_t mapSize = 1024u << std::clamp(get_int_option(g_cvarMapSize, 1), 0, 2); + const bool cameraReplayDebug = debugMode == 10; + const float radius = + static_cast(std::clamp(get_int_option(g_cvarBoxRadius, 6000), 1000, 20000)); + LightCamera lightCamera{}; + if (!build_light_camera(replayViewMtx, mapSize, radius, lightCamera)) { + return; + } + Mtx44 lightReplayProjection; + if (!build_light_replay_projection(lightCamera, replayViewMtx, lightReplayProjection)) { + return; + } + f32 savedProjection[7]; + GXGetProjectionv(savedProjection); + f32 savedViewport[6]; + GXGetViewportv(savedViewport); + u32 savedScissor[4]; + GXGetScissor(&savedScissor[0], &savedScissor[1], &savedScissor[2], &savedScissor[3]); + Mtx savedView; + cMtx_copy(j3dSys.getViewMtx(), savedView); + + auto restore_game_camera = [&]() { + j3dSys.setViewMtx(savedView); + GXSetProjectionv(savedProjection); + GXSetViewport(savedViewport[0], savedViewport[1], savedViewport[2], savedViewport[3], + savedViewport[4], savedViewport[5]); + GXSetScissor(savedScissor[0], savedScissor[1], savedScissor[2], savedScissor[3]); + dKy_setLight(); + }; + auto set_replay_camera = [&]() { + j3dSys.setViewMtx(replayViewMtx); + if (cameraReplayDebug) { + GXSetProjectionv(replayProjection); + } else { + GXSetProjectionFull(lightReplayProjection); + } + dKy_setLight(); + }; + if (!draw_lists_ready()) { + return; + } + if (svc_gfx->create_pass(mod_ctx, mapSize, mapSize) != MOD_OK) { + return; + } + J3DShape::resetVcdVatCache(); + + set_replay_camera(); + GXSetViewport(0.0f, 0.0f, static_cast(mapSize), static_cast(mapSize), 0.0f, 1.0f); + GXSetViewportRender( + 0.0f, 0.0f, static_cast(mapSize), static_cast(mapSize), 0.0f, 1.0f); + GXSetScissorRender(0, 0, mapSize, mapSize); + dKy_setLight(); + GXSetColorUpdate(GX_TRUE); + GXSetAlphaUpdate(GX_TRUE); + GXSetZMode(GX_TRUE, GX_LEQUAL, GX_TRUE); + { + replay_scope replay; + draw_opaque_scene_lists(); + } + j3dSys.reinitGX(); + J3DShape::resetVcdVatCache(); + restore_game_camera(); + + GfxResolveDesc resolveDesc = GFX_RESOLVE_DESC_INIT; + resolveDesc.color = true; + resolveDesc.depth = true; + GfxResolvedTargets resolved = GFX_RESOLVED_TARGETS_INIT; + if (svc_gfx->resolve_pass(mod_ctx, &resolveDesc, &resolved) != MOD_OK || + resolved.color == nullptr || resolved.depth == nullptr) + { + return; + } + + j3dSys.reinitGX(); + J3DShape::resetVcdVatCache(); + restore_game_camera(); + + g_mapPass.lightColor = resolved.color; + g_mapPass.shadowMap = resolved.depth; + g_mapPass.mapSize = mapSize; + copy_projection(lightCamera.vp, g_mapPass.lightVp); + std::memcpy( + g_mapPass.dirToLightWorld, lightCamera.dirToLight, sizeof(g_mapPass.dirToLightWorld)); + g_mapPass.fade = lightCamera.fade; + g_mapPass.ready = true; +} + +// Game thread, after the full 3D scene: deferred composite. +void on_frame_before_hud(ModContext*, const GfxStageContext*, void*) { + const int64_t debugMode = get_debug_mode(); + restore_actual_light_debug(); + + const MapPassOutput mapPass = std::exchange(g_mapPass, {}); + if (debugMode == 9) { + return; + } + if (!mapPass.ready || mapPass.shadowMap == nullptr || mapPass.lightColor == nullptr) { + return; + } + if (!g_sceneCamera.valid) { + return; + } + const CameraInfo& camera = g_sceneCamera.info; + + GfxResolveDesc resolveDesc = GFX_RESOLVE_DESC_INIT; + resolveDesc.color = false; + resolveDesc.depth = true; + GfxResolvedTargets resolved = GFX_RESOLVED_TARGETS_INIT; + if (svc_gfx->resolve_pass(mod_ctx, &resolveDesc, &resolved) != MOD_OK || + resolved.depth == nullptr) + { + return; + } + + ShadowUniforms uniforms{}; + std::memcpy(uniforms.world_from_proj, camera.world_from_proj, sizeof(uniforms.world_from_proj)); + std::memcpy(uniforms.view_from_proj, camera.view_from_proj, sizeof(uniforms.view_from_proj)); + std::memcpy(uniforms.proj_from_view, camera.proj_from_view, sizeof(uniforms.proj_from_view)); + store_column_major(mapPass.lightVp, uniforms.light_vp); + // Rotate the world-space light direction into view space (w = 0). + for (int r = 0; r < 3; ++r) { + uniforms.light_dir_view[r] = + camera.view_from_world[0 * 4 + r] * mapPass.dirToLightWorld[0] + + camera.view_from_world[1 * 4 + r] * mapPass.dirToLightWorld[1] + + camera.view_from_world[2 * 4 + r] * mapPass.dirToLightWorld[2]; + } + // Bias is configured in world units along the light direction. + uniforms.bias = + static_cast(std::clamp(get_int_option(g_cvarBias, 15), 0, 200)) / + (kLightFar - kLightNear); + uniforms.size[0] = static_cast(mapPass.mapSize); + uniforms.size[1] = static_cast(mapPass.mapSize); + uniforms.inv_size[0] = 1.0f / uniforms.size[0]; + uniforms.inv_size[1] = 1.0f / uniforms.size[1]; + uniforms.strength = + mapPass.fade * + static_cast(std::clamp(get_int_option(g_cvarStrength, 45), 0, 100)) / + 100.0f; + uniforms.pcf_taps = static_cast(std::clamp(get_int_option(g_cvarPcf, 1), 0, 2)); + uniforms.contact_enabled = get_bool_option(g_cvarContactShadows, false) ? 1.0f : 0.0f; + uniforms.contact_thickness = 25.0f; + uniforms.contact_length = 60.0f; + uniforms.debug_mode = static_cast(debugMode); + + GfxRange uniformRange{0, 0}; + if (svc_gfx->push_uniform(mod_ctx, &uniforms, sizeof(uniforms), &uniformRange) != MOD_OK) { + return; + } + const DrawPayload payload{resolved.depth, mapPass.shadowMap, mapPass.lightColor, + uniformRange.offset, uniformRange.size, static_cast(debugMode)}; + svc_gfx->push_draw(mod_ctx, g_drawType, &payload, sizeof(payload)); +} + +void add_control(UiElementHandle pane, const UiControlDesc& desc) { + svc_ui->pane_add_control(mod_ctx, pane, &desc, nullptr); +} + +void add_toggle(UiElementHandle pane, const char* label, ConfigVarHandle cvar, const char* help) { + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_TOGGLE; + control.label = label; + control.help_rml = help; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = cvar; + add_control(pane, control); +} + +void add_select(UiElementHandle pane, const char* label, ConfigVarHandle cvar, const char** options, + uint32_t optionCount, const char* help) { + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_SELECT; + control.label = label; + control.help_rml = help; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = cvar; + control.options = options; + control.option_count = optionCount; + add_control(pane, control); +} + +void add_number(UiElementHandle pane, const char* label, ConfigVarHandle cvar, int64_t min, + int64_t max, int64_t step, const char* suffix, const char* help) { + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_NUMBER; + control.label = label; + control.help_rml = help; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = cvar; + control.min = min; + control.max = max; + control.step = step; + control.suffix = suffix; + add_control(pane, control); +} + +ModResult build_controls_tab( + ModContext*, UiWindowHandle, UiElementHandle left, UiElementHandle right, void*, ModError*) { + (void)right; + + svc_ui->pane_add_section(mod_ctx, left, "Shadow Map"); + add_toggle(left, "Enabled", g_cvarEnabled, "Enables dynamic shadows."); + static const char* kMapSizes[] = {"1024", "2048", "4096"}; + add_select(left, "Map Size", g_cvarMapSize, kMapSizes, 3, + "Shadow map resolution. Larger is sharper and slower."); + add_toggle(left, "No Frustum Clipping", g_cvarNoFrustumClipping, + "Keeps camera-frustum-culled objects in draw lists so off-screen objects can cast " + "dynamic shadows. This can be expensive."); + add_number(left, "Coverage", g_cvarBoxRadius, 1000, 20000, 500, nullptr, + "Radius of the shadowed area around the camera, in world units. Smaller is sharper."); + + svc_ui->pane_add_section(mod_ctx, left, "Appearance"); + add_number(left, "Strength", g_cvarStrength, 0, 100, 5, "%", "How dark shadowed areas become."); + static const char* kPcfOptions[] = {"Off", "3x3", "5x5"}; + add_select(left, "Soft Shadows", g_cvarPcf, kPcfOptions, 3, + "Percentage-closer filtering tap pattern; softens shadow edges."); + add_number(left, "Bias", g_cvarBias, 0, 200, 5, nullptr, + "Depth bias in world units. Raise to remove shadow acne; lower to reduce peter-panning."); + add_toggle(left, "Contact Shadows", g_cvarContactShadows, + "Adds a screen-space raymarch for small-scale contact darkening the map misses."); + + svc_ui->pane_add_section(mod_ctx, left, "Debug"); + static const char* kDebugOptions[] = {"Off", "Shadow Map", "Shadow Factor", "Occlusion", + "Light UV", "Compare Sign", "Depth Values", "Receiver Range", "Bounds", "Light View", + "Camera Replay"}; + add_select(left, "Debug View", g_cvarDebugView, kDebugOptions, 11, + "Shadow Map: light-space depth buffer
Shadow Factor: final " + "darkening term
Occlusion: map comparison result
Light UV: receiver " + "projection coverage
Compare Sign: current comparison in red and opposite " + "comparison in blue
Depth Values: receiver depth in red and map depth in green
" + "Receiver Range: beyond-far in red, valid depth in green, and before-near in blue
" + "Bounds: valid X in red, valid Y in green, and valid depth in blue
Light View: " + "renders the game world directly from the light camera
Camera Replay: " + "captures the same draw-list replay from the gameplay camera"); + return MOD_OK; +} + +void on_controls_window_closed(ModContext*, UiWindowHandle, void*) { + g_controlsWindow = 0; +} + +void on_open_controls(ModContext*, void*) { + if (g_controlsWindow != 0) { + return; + } + UiTabDesc tabs[1] = {UI_TAB_DESC_INIT}; + tabs[0].title = "Controls"; + tabs[0].build = build_controls_tab; + UiWindowDesc desc = UI_WINDOW_DESC_INIT; + desc.tabs = tabs; + desc.tab_count = 1; + desc.on_closed = on_controls_window_closed; + if (svc_ui->window_push(mod_ctx, &desc, &g_controlsWindow) != MOD_OK) { + svc_log->error(mod_ctx, "failed to open shadow controls window"); + } +} + +ModResult build_panel(ModContext*, UiElementHandle panel, void*, ModError*) { + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_TOGGLE; + control.label = "Enabled"; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = g_cvarEnabled; + add_control(panel, control); + + control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_BUTTON; + control.label = "Open Controls"; + control.on_pressed = on_open_controls; + add_control(panel, control); + return MOD_OK; +} + +ModResult register_bool_option( + const char* name, bool defaultValue, ConfigVarHandle& outHandle, ModError* error) { + ConfigVarDesc cvarDesc = CONFIG_VAR_DESC_INIT; + cvarDesc.name = name; + cvarDesc.type = CONFIG_VAR_BOOL; + cvarDesc.default_bool = defaultValue; + if (svc_config->register_var(mod_ctx, &cvarDesc, &outHandle) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register shadow option"); + } + return MOD_OK; +} + +ModResult register_int_option( + const char* name, int64_t defaultValue, ConfigVarHandle& outHandle, ModError* error) { + ConfigVarDesc cvarDesc = CONFIG_VAR_DESC_INIT; + cvarDesc.name = name; + cvarDesc.type = CONFIG_VAR_INT; + cvarDesc.default_int = defaultValue; + if (svc_config->register_var(mod_ctx, &cvarDesc, &outHandle) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register shadow option"); + } + return MOD_OK; +} + +} // namespace + +extern "C" { + +MOD_EXPORT ModResult mod_initialize(ModError* error) { + ModResult result = svc_resource->load(mod_ctx, "shadow.wgsl", &g_shaderSource); + if (result != MOD_OK || g_shaderSource.data == nullptr) { + return dusk::mods::set_error(error, result, "failed to load shadow.wgsl"); + } + + result = register_bool_option("effectEnabled", false, g_cvarEnabled, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("mapSize", 2, g_cvarMapSize, error); + if (result != MOD_OK) { + return result; + } + result = register_bool_option("noFrustumClipping", true, g_cvarNoFrustumClipping, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("strength", 45, g_cvarStrength, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("pcf", 2, g_cvarPcf, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("bias", 55, g_cvarBias, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("boxRadius", 6000, g_cvarBoxRadius, error); + if (result != MOD_OK) { + return result; + } + result = register_bool_option("contactShadows", true, g_cvarContactShadows, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("debugView", 0, g_cvarDebugView, error); + if (result != MOD_OK) { + return result; + } + + if (svc_gfx->get_device_info(mod_ctx, &g_deviceInfo) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to query device info"); + } + if (!build_composite_pipeline(true, g_compositePipeline, g_compositeLayout) || + !build_composite_pipeline(false, g_compositeDebugPipeline, g_compositeDebugLayout)) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to create composite pipeline"); + } + + GfxDrawTypeDesc drawDesc = GFX_DRAW_TYPE_DESC_INIT; + drawDesc.label = "shadow composite"; + drawDesc.draw = on_draw; + if (svc_gfx->register_draw_type(mod_ctx, &drawDesc, &g_drawType) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register draw type"); + } + GfxStageHookDesc stageDesc = GFX_STAGE_HOOK_DESC_INIT; + stageDesc.callback = on_scene_begin; + if (svc_gfx->register_stage_hook( + mod_ctx, GFX_STAGE_SCENE_BEGIN, &stageDesc, &g_sceneBeginHook) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register stage hook"); + } + stageDesc.callback = on_scene_after_terrain; + if (svc_gfx->register_stage_hook( + mod_ctx, GFX_STAGE_SCENE_AFTER_TERRAIN, &stageDesc, &g_sceneAfterTerrainHook) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register stage hook"); + } + stageDesc.callback = on_frame_before_hud; + if (svc_gfx->register_stage_hook( + mod_ctx, GFX_STAGE_FRAME_BEFORE_HUD, &stageDesc, &g_frameBeforeHudHook) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register stage hook"); + } + + // Skip the game's own shadow rendering while the dynamic pass is active: the + // shadowControl pair covers the actor real/blob shadows, drawCloudShadow the weather + // cloud shadows. + if (dusk::mods::hook_add_pre<&dDlst_shadowControl_c::imageDraw>(svc_hook, on_game_shadow_pre) != + MOD_OK || + dusk::mods::hook_add_pre<&dDlst_shadowControl_c::draw>(svc_hook, on_game_shadow_pre) != + MOD_OK || + dusk::mods::hook_add_pre<&drawCloudShadow>(svc_hook, on_game_shadow_pre) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to hook game shadow rendering"); + } + if (dusk::mods::hook_add_pre(svc_hook, on_frustum_clip_pre) != MOD_OK || + dusk::mods::hook_add_pre(svc_hook, on_frustum_clip_pre) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to hook frustum clipping"); + } + if (dusk::mods::hook_add_pre(svc_hook, on_copy_tex_pre) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to hook GXCopyTex"); + } + UiModsPanelDesc panelDesc = UI_MODS_PANEL_DESC_INIT; + panelDesc.build = build_panel; + svc_ui->register_mods_panel(mod_ctx, &panelDesc); + + return MOD_OK; +} + +MOD_EXPORT ModResult mod_update(ModError*) { + return MOD_OK; +} + +MOD_EXPORT ModResult mod_shutdown(ModError*) { + restore_actual_light_debug(); + svc_resource->free(mod_ctx, &g_shaderSource); + if (g_compositePipeline != nullptr) { + wgpuRenderPipelineRelease(g_compositePipeline); + g_compositePipeline = nullptr; + } + if (g_compositeDebugPipeline != nullptr) { + wgpuRenderPipelineRelease(g_compositeDebugPipeline); + g_compositeDebugPipeline = nullptr; + } + if (g_compositeLayout != nullptr) { + wgpuBindGroupLayoutRelease(g_compositeLayout); + g_compositeLayout = nullptr; + } + if (g_compositeDebugLayout != nullptr) { + wgpuBindGroupLayoutRelease(g_compositeDebugLayout); + g_compositeDebugLayout = nullptr; + } + g_cvarEnabled = g_cvarMapSize = g_cvarNoFrustumClipping = 0; + g_cvarStrength = 0; + g_cvarPcf = g_cvarBias = g_cvarBoxRadius = g_cvarContactShadows = g_cvarDebugView = 0; + g_drawType = g_sceneBeginHook = g_sceneAfterTerrainHook = g_frameBeforeHudHook = 0; + g_controlsWindow = 0; + g_mapPass = {}; + g_sceneCamera.valid = false; + g_sceneCamera.raw_valid = false; + return MOD_OK; +} +}