From 7df07c6904dcfdb61fa1088255723e4617552bde Mon Sep 17 00:00:00 2001 From: Luke Street Date: Thu, 9 Jul 2026 18:54:44 -0600 Subject: [PATCH 01/10] Mod API: Camera service --- docs/modding.md | 19 +++++ files.cmake | 1 + include/mods/svc/camera.h | 64 ++++++++++++++++ src/dusk/mods/svc/camera.cpp | 134 +++++++++++++++++++++++++++++++++ src/dusk/mods/svc/registry.cpp | 1 + src/dusk/mods/svc/registry.hpp | 1 + 6 files changed, 220 insertions(+) create mode 100644 include/mods/svc/camera.h create mode 100644 src/dusk/mods/svc/camera.cpp diff --git a/docs/modding.md b/docs/modding.md index 7b616989e8..031f08c602 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -409,6 +409,25 @@ existing documents restyle immediately, and future ones pick it up when created. host styles and may override them. Scope selectors tightly (use `[mod-id="..."]`!), especially for `UI_SCOPE_WINDOW`, unless changing host UI is intentional. +### CameraService (`mods/svc/camera.h`) + +Converts a game view provided by a render callback into WebGPU-convention camera data. Matrix fields are column-major +`float[16]` values using the matrix * column-vector convention (transpose of the game's row-major `Mtx`/`Mtx44` layout), +ready to copy into WGSL `mat4x4f` uniforms. + +```cpp +IMPORT_SERVICE(CameraService, svc_camera); + +CameraInfo camera = CAMERA_INFO_INIT; +if (svc_camera->get_camera(mod_ctx, game_view, &camera) == MOD_OK) { + // camera.view_from_world, camera.proj_from_view, camera.eye, ... +} +``` + +`get_camera` returns `MOD_UNAVAILABLE` while the view is not a valid perspective camera, such as before the +first in-game frame. Projection matrices match the renderer's WebGPU clip convention and renderer depth convention +(reversed-Z by default). + --- ## Hooking Game Functions diff --git a/files.cmake b/files.cmake index 15b64c3a3f..358d2373c1 100644 --- a/files.cmake +++ b/files.cmake @@ -1562,6 +1562,7 @@ set(DUSK_FILES src/dusk/mods/loader/loader.hpp src/dusk/mods/loader/native_module.cpp src/dusk/mods/loader/native_module.hpp + src/dusk/mods/svc/camera.cpp src/dusk/mods/svc/config.cpp src/dusk/mods/svc/config.hpp src/dusk/mods/svc/game.cpp diff --git a/include/mods/svc/camera.h b/include/mods/svc/camera.h new file mode 100644 index 0000000000..f974cec152 --- /dev/null +++ b/include/mods/svc/camera.h @@ -0,0 +1,64 @@ +#pragma once + +#include "mods/api.h" + +#define CAMERA_SERVICE_ID "dev.twilitrealm.dusklight.camera" +#define CAMERA_SERVICE_MAJOR 1u +#define CAMERA_SERVICE_MINOR 0u + +/* + * Snapshot of a game camera for the frame currently being recorded. + * + * Matrix conventions: every matrix is a column-major float[16] using the matrix * column-vector + * convention, ready to memcpy into a WGSL mat4x4f uniform. NOTE: this is the TRANSPOSE of the + * game's row-major Mtx/Mtx44 layout; mods that want the raw game matrices should read the + * view_class directly instead. + * + * View space is right-handed with -Z forward. Projection matrices are in WebGPU clip convention + * and follow the renderer's depth mode: reversed-Z by default (depth 1.0 at the near plane, + * 0.0 at far). + * + * Unprojecting a depth-buffer texel at uv with sampled depth d: + * let ndc = vec3f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, d); // WebGPU framebuffer y is down + * let world4 = world_from_proj * vec4f(ndc, 1.0); + * let world = world4.xyz / world4.w; + */ +typedef struct CameraInfo { + uint32_t struct_size; + + float view_from_world[16]; /* the view matrix */ + float world_from_view[16]; /* its inverse; column 3 is the camera position */ + float proj_from_view[16]; /* WebGPU-convention projection (+ Aurora reversed-Z) */ + float view_from_proj[16]; /* its inverse */ + float proj_from_world[16]; /* proj_from_view * view_from_world */ + float world_from_proj[16]; /* one-step depth-buffer -> world unproject */ + + float eye[3]; /* camera position in world space */ + float fovy; /* vertical field of view, degrees */ + float aspect; + float near_plane; + float far_plane; +} CameraInfo; + +#define CAMERA_INFO_INIT {sizeof(CameraInfo)} + +typedef struct CameraService { + ServiceHeader header; + + /* + * Snapshots a camera. game_view must be a view_class pointer, such as from a render stage + * callback's game view. Game thread only. Returns MOD_UNAVAILABLE when the view is not a valid + * perspective camera. + */ + ModResult (*get_camera)(ModContext* ctx, const void* game_view, CameraInfo* out_info); +} CameraService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = CAMERA_SERVICE_ID; + static constexpr uint16_t major_version = CAMERA_SERVICE_MAJOR; +}; +#endif diff --git a/src/dusk/mods/svc/camera.cpp b/src/dusk/mods/svc/camera.cpp new file mode 100644 index 0000000000..37057e2397 --- /dev/null +++ b/src/dusk/mods/svc/camera.cpp @@ -0,0 +1,134 @@ +#include "registry.hpp" + +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/camera.h" + +#include "f_op/f_op_view.h" +#include "m_Do/m_Do_mtx.h" + +#include +#include + +namespace dusk::mods::svc { +namespace { + +void to_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 store_affine(const Mtx in, float out[16]) { + for (int c = 0; c < 4; ++c) { + for (int r = 0; r < 3; ++r) { + out[c * 4 + r] = in[r][c]; + } + out[c * 4 + 3] = 0.0f; + } + out[15] = 1.0f; +} + +/* affine * 4x4; operand-order mirror of cMtx_concatProjView */ +void concat_affine_proj(const Mtx a, const Mtx44 b, Mtx44 out) { + for (int r = 0; r < 3; ++r) { + for (int c = 0; c < 4; ++c) { + out[r][c] = + a[r][0] * b[0][c] + a[r][1] * b[1][c] + a[r][2] * b[2][c] + a[r][3] * b[3][c]; + } + } + std::memcpy(out[3], b[3], sizeof(f32) * 4); +} + +ModResult snapshot_view(const view_class* view, CameraInfo* outInfo) { + if (view == nullptr || !(view->near_ > 0.0f) || !(view->far_ > view->near_) || + !(view->fovy > 0.0f) || view->fovy >= 180.0f || !(view->aspect > 0.0f)) + { + return MOD_UNAVAILABLE; + } + + // Build from the GXSetProjection values + const f32 p00 = view->projMtx[0][0]; + const f32 p02 = view->projMtx[0][2]; + const f32 p11 = view->projMtx[1][1]; + const f32 p12 = view->projMtx[1][2]; + const f32 p22 = view->projMtx[2][2]; + const f32 p23 = view->projMtx[2][3]; + if (view->projMtx[3][2] != -1.0f || p00 == 0.0f || p11 == 0.0f || p23 == 0.0f) { + return MOD_UNAVAILABLE; + } + + // WebGPU-convention projection + Aurora reversed Z + const bool reversedZ = aurora::gfx::uses_reversed_z(); + const f32 e = reversedZ ? -p22 : p22 - 1.0f; + const f32 f = reversedZ ? -p23 : p23; + Mtx44 proj{}; + proj[0][0] = p00; + proj[0][2] = p02; + proj[1][1] = p11; + proj[1][2] = p12; + proj[2][2] = e; + proj[2][3] = f; + proj[3][2] = -1.0f; + + // Analytic inverse of the sparse perspective form + Mtx44 invProj{}; + invProj[0][0] = 1.0f / p00; + invProj[0][3] = p02 / p00; + invProj[1][1] = 1.0f / p11; + invProj[1][3] = p12 / p11; + invProj[2][3] = -1.0f; + invProj[3][2] = 1.0f / f; + invProj[3][3] = e / f; + + Mtx44 projWorld; + cMtx_concatProjView(proj, view->viewMtx, projWorld); + Mtx44 worldProj; + concat_affine_proj(view->invViewMtx, invProj, worldProj); + + store_affine(view->viewMtx, outInfo->view_from_world); + store_affine(view->invViewMtx, outInfo->world_from_view); + to_column_major(proj, outInfo->proj_from_view); + to_column_major(invProj, outInfo->view_from_proj); + to_column_major(projWorld, outInfo->proj_from_world); + to_column_major(worldProj, outInfo->world_from_proj); + + outInfo->eye[0] = view->lookat.eye.x; + outInfo->eye[1] = view->lookat.eye.y; + outInfo->eye[2] = view->lookat.eye.z; + outInfo->fovy = view->fovy; + outInfo->aspect = view->aspect; + outInfo->near_plane = view->near_; + outInfo->far_plane = view->far_; + return MOD_OK; +} + +ModResult camera_get_camera_from_view( + ModContext* context, const void* gameView, CameraInfo* outInfo) { + if (outInfo == nullptr || outInfo->struct_size < sizeof(CameraInfo) || + mod_from_context(context) == nullptr || gameView == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + const uint32_t structSize = outInfo->struct_size; + std::memset(outInfo, 0, sizeof(CameraInfo)); + outInfo->struct_size = structSize; + return snapshot_view(static_cast(gameView), outInfo); +} + +constexpr CameraService s_cameraService{ + .header = SERVICE_HEADER(CameraService, CAMERA_SERVICE_MAJOR, CAMERA_SERVICE_MINOR), + .get_camera = camera_get_camera_from_view, +}; + +} // namespace + +constinit const ServiceModule g_cameraModule{ + .id = CAMERA_SERVICE_ID, + .majorVersion = CAMERA_SERVICE_MAJOR, + .minorVersion = CAMERA_SERVICE_MINOR, + .service = &s_cameraService, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index 206acc8994..f9e7f96476 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -208,6 +208,7 @@ void ModLoader::init_services() { &svc::g_configModule, &svc::g_uiModule, &svc::g_gameModule, + &svc::g_cameraModule, }) { svc::register_module(*module); diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index 71fda98abd..b4b76cd510 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -70,5 +70,6 @@ extern const ServiceModule g_textureModule; extern const ServiceModule g_configModule; extern const ServiceModule g_uiModule; extern const ServiceModule g_gameModule; +extern const ServiceModule g_cameraModule; } // namespace dusk::mods::svc From d9a978f21fbd2d0083ef52bc9ba8eaffc1e3d85a Mon Sep 17 00:00:00 2001 From: Luke Street Date: Thu, 9 Jul 2026 23:08:59 -0600 Subject: [PATCH 02/10] Mod API: gfx service --- docs/modding.md | 29 ++ files.cmake | 1 + include/dusk/gfx.hpp | 10 + include/mods/svc/gfx.h | 209 ++++++++ src/dusk/mods/svc/gfx.cpp | 846 +++++++++++++++++++++++++++++++++ src/dusk/mods/svc/registry.cpp | 1 + src/dusk/mods/svc/registry.hpp | 1 + src/m_Do/m_Do_graphic.cpp | 21 + 8 files changed, 1118 insertions(+) create mode 100644 include/dusk/gfx.hpp create mode 100644 include/mods/svc/gfx.h create mode 100644 src/dusk/mods/svc/gfx.cpp diff --git a/docs/modding.md b/docs/modding.md index 031f08c602..b2e7ba2e26 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -409,6 +409,35 @@ existing documents restyle immediately, and future ones pick it up when created. host styles and may override them. Scope selectors tightly (use `[mod-id="..."]`!), especially for `UI_SCOPE_WINDOW`, unless changing host UI is intentional. +### GfxService (`mods/svc/gfx.h`) + +Direct WebGPU access at various stages of the rendering pipeline. Mods use the `wgpu*` C API (via `webgpu/webgpu.h`) for +custom draws and compute dispatches. Mods must manage their own WebGPU state, including pipelines and bind groups. + +```cpp +IMPORT_SERVICE(GfxService, svc_gfx); + +GfxDeviceInfo info = GFX_DEVICE_INFO_INIT; +svc_gfx->get_device_info(mod_ctx, &info); +``` + +`register_stage_hook` runs a game-thread callback during frame recording. The public stages are: + +- `GFX_STAGE_SCENE_BEGIN`: world camera window after camera/projection/light setup +- `GFX_STAGE_SCENE_AFTER_TERRAIN`: after terrain/shadow lists, before object and translucent lists +- `GFX_STAGE_SCENE_AFTER_OPAQUE`: after sky/terrain/object opaque lists, before translucent lists +- `GFX_STAGE_FRAME_BEFORE_HUD`: 3D scene and wipe are complete, before 2D/HUD lists +- `GFX_STAGE_FRAME_AFTER_HUD`: full game scene, including HUD + +Inside a stage callback, record work with `push_draw`, stream per-frame data with `push_verts`, `push_indices`, +`push_uniform`, or `push_storage`, snapshot the current frame with `resolve_pass`, and use `create_pass`/`resolve_pass` +for temporary offscreen passes. Draw callbacks run later on the render worker thread with the live +`WGPURenderPassEncoder`; they may use only their `GfxDrawContext` handles and raw `wgpu*` calls. Compute callbacks +registered with `register_compute_type` follow the same worker-thread rule and run on the frame command encoder. + +All WGPU handles from the service are borrowed. Resolved target views are valid for the current frame only. GPU objects +created by a mod are owned by that mod and should be released in `mod_shutdown`. + ### CameraService (`mods/svc/camera.h`) Converts a game view provided by a render callback into WebGPU-convention camera data. Matrix fields are column-major diff --git a/files.cmake b/files.cmake index 358d2373c1..c1546f1db0 100644 --- a/files.cmake +++ b/files.cmake @@ -1566,6 +1566,7 @@ set(DUSK_FILES src/dusk/mods/svc/config.cpp src/dusk/mods/svc/config.hpp src/dusk/mods/svc/game.cpp + src/dusk/mods/svc/gfx.cpp src/dusk/mods/svc/hook.cpp src/dusk/mods/svc/host.cpp src/dusk/mods/svc/log.cpp diff --git a/include/dusk/gfx.hpp b/include/dusk/gfx.hpp new file mode 100644 index 0000000000..adb04bee51 --- /dev/null +++ b/include/dusk/gfx.hpp @@ -0,0 +1,10 @@ +#pragma once + +#include "mods/svc/gfx.h" + +namespace dusk::mods { + +void gfx_run_stage(GfxStage stage, const view_class* gameView = nullptr, + const view_port_class* gameViewport = nullptr); + +} // namespace dusk::mods diff --git a/include/mods/svc/gfx.h b/include/mods/svc/gfx.h new file mode 100644 index 0000000000..a9812fd044 --- /dev/null +++ b/include/mods/svc/gfx.h @@ -0,0 +1,209 @@ +#pragma once + +#include "mods/api.h" + +#include + +/* + * Direct WebGPU access at various stages of the rendering pipeline. Mods use the wgpu* C API + * (via webgpu/webgpu.h) for custom draws and compute dispatches. + * + * Every service function must be called on the game thread. GfxStageFn callbacks run on the game + * thread during frame recording. push_draw, push_* and pass functions are valid from a stage + * callback and anywhere else GX commands are being recorded. + * + * GfxDrawFn and GfxComputeFn callbacks run on the render worker thread while the frame is encoded. + * They may use only the handles in their context struct and raw wgpu* calls; no other service may + * be called from them. + * + * All WGPU handles provided by this service are borrowed. Handles in callback contexts are valid + * only for the duration of the callback; views in GfxResolvedTargets are valid for the current + * frame only. GPU objects a mod creates through raw wgpu calls are its own responsibility and + * should be released in mod_shutdown. The device outlives all mods. + */ + +#define GFX_SERVICE_ID "dev.twilitrealm.dusklight.gfx" +#define GFX_SERVICE_MAJOR 1u +#define GFX_SERVICE_MINOR 0u + +/* Maximum size for push_draw payload */ +#define GFX_INLINE_DRAW_PAYLOAD_SIZE 128u + +/* 0 is never a valid handle. */ +typedef uint64_t GfxDrawTypeHandle; +typedef uint64_t GfxStageHookHandle; +typedef uint64_t GfxComputeTypeHandle; + +/* A suballocation in one of the shared per-frame streaming buffers. */ +typedef struct GfxRange { + uint32_t offset; + uint32_t size; +} GfxRange; + +/* + * Device and scene pass configuration. Valid from mod_initialize onward and stable for the + * session. Offscreen passes from create_pass are always single-sample. + */ +typedef struct GfxDeviceInfo { + uint32_t struct_size; + WGPUDevice device; /* borrowed */ + WGPUQueue queue; /* borrowed */ + WGPUTextureFormat color_format; /* scene color target format */ + WGPUTextureFormat depth_format; /* scene depth target format */ + uint32_t sample_count; /* scene pass MSAA sample count */ + bool uses_reversed_z; /* true means depth 1.0 is near */ +} GfxDeviceInfo; + +#define GFX_DEVICE_INFO_INIT \ + {sizeof(GfxDeviceInfo), NULL, NULL, WGPUTextureFormat_Undefined, WGPUTextureFormat_Undefined, \ + 1u, false} + +/* + * Passed to GfxDrawFn on the render worker thread; valid only during the call. The pass pipeline, + * bind group, viewport, and scissor state is restored by the host after the callback returns. + */ +typedef struct GfxDrawContext { + uint32_t struct_size; + WGPUDevice device; + WGPUQueue queue; + WGPURenderPassEncoder pass; + WGPUBuffer vertex_buffer; + WGPUBuffer index_buffer; + WGPUBuffer uniform_buffer; + WGPUBuffer storage_buffer; + WGPUTextureFormat color_format; + WGPUTextureFormat depth_format; + uint32_t sample_count; + uint32_t target_width; + uint32_t target_height; + bool uses_reversed_z; +} GfxDrawContext; + +typedef void (*GfxDrawFn)(ModContext* ctx, const GfxDrawContext* draw_ctx, const void* payload, + size_t payload_size, void* user_data); + +typedef struct GfxDrawTypeDesc { + uint32_t struct_size; + const char* label; /* optional debug label */ + GfxDrawFn draw; /* required; called from the render worker thread */ + void* user_data; +} GfxDrawTypeDesc; + +#define GFX_DRAW_TYPE_DESC_INIT {sizeof(GfxDrawTypeDesc), NULL, NULL, NULL} + +typedef enum GfxStage { + GFX_STAGE_SCENE_AFTER_TERRAIN = 0, + GFX_STAGE_FRAME_BEFORE_HUD = 1, + GFX_STAGE_FRAME_AFTER_HUD = 2, + GFX_STAGE_SCENE_BEGIN = 3, + GFX_STAGE_SCENE_AFTER_OPAQUE = 4, +} GfxStage; + +typedef struct GfxStageContext { + uint32_t struct_size; + GfxStage stage; + const void* game_view; /* view_class* for world-camera stages; NULL otherwise */ + const void* game_viewport; /* view_port_class* for world-camera stages; NULL otherwise */ +} GfxStageContext; + +typedef void (*GfxStageFn)(ModContext* ctx, const GfxStageContext* stage_ctx, void* user_data); + +typedef struct GfxStageHookDesc { + uint32_t struct_size; + GfxStageFn callback; /* required */ + void* user_data; +} GfxStageHookDesc; + +#define GFX_STAGE_HOOK_DESC_INIT {sizeof(GfxStageHookDesc), NULL, NULL} + +typedef struct GfxResolveDesc { + uint32_t struct_size; + bool color; + bool depth; +} GfxResolveDesc; + +#define GFX_RESOLVE_DESC_INIT {sizeof(GfxResolveDesc), true, false} + +typedef struct GfxResolvedTargets { + uint32_t struct_size; + WGPUTextureView color; /* single-sample snapshot in color_format */ + WGPUTextureView depth; /* single-sample raw depth snapshot, R32Float when available */ + WGPUTextureFormat color_format; + uint32_t width; + uint32_t height; +} GfxResolvedTargets; + +#define GFX_RESOLVED_TARGETS_INIT \ + {sizeof(GfxResolvedTargets), NULL, NULL, WGPUTextureFormat_Undefined, 0u, 0u} + +/* + * Passed to GfxComputeFn on the render worker thread; valid only during the call. The encoder is + * the frame command encoder between scene render passes. Leave no pass open and never finish or + * release the encoder. + */ +typedef struct GfxComputeContext { + uint32_t struct_size; + WGPUDevice device; + WGPUQueue queue; + WGPUCommandEncoder encoder; + WGPUBuffer vertex_buffer; + WGPUBuffer index_buffer; + WGPUBuffer uniform_buffer; + WGPUBuffer storage_buffer; +} GfxComputeContext; + +typedef void (*GfxComputeFn)(ModContext* ctx, const GfxComputeContext* compute_ctx, + const void* payload, size_t payload_size, void* user_data); + +typedef struct GfxComputeTypeDesc { + uint32_t struct_size; + const char* label; /* optional debug label */ + GfxComputeFn callback; /* required; called from the render worker thread */ + void* user_data; +} GfxComputeTypeDesc; + +#define GFX_COMPUTE_TYPE_DESC_INIT {sizeof(GfxComputeTypeDesc), NULL, NULL, NULL} + +typedef struct GfxService { + ServiceHeader header; + + ModResult (*get_device_info)(ModContext* ctx, GfxDeviceInfo* out_info); + void* (*get_proc_address)(ModContext* ctx, const char* name); + + ModResult (*register_draw_type)( + ModContext* ctx, const GfxDrawTypeDesc* desc, GfxDrawTypeHandle* out_handle); + ModResult (*unregister_draw_type)(ModContext* ctx, GfxDrawTypeHandle handle); + ModResult (*push_draw)( + ModContext* ctx, GfxDrawTypeHandle handle, const void* payload, size_t payload_size); + + ModResult (*register_compute_type)( + ModContext* ctx, const GfxComputeTypeDesc* desc, GfxComputeTypeHandle* out_handle); + ModResult (*unregister_compute_type)(ModContext* ctx, GfxComputeTypeHandle handle); + ModResult (*push_compute)( + ModContext* ctx, GfxComputeTypeHandle handle, const void* payload, size_t payload_size); + + ModResult (*push_verts)( + ModContext* ctx, const void* data, size_t size, size_t alignment, GfxRange* out_range); + ModResult (*push_indices)( + ModContext* ctx, const void* data, size_t size, size_t alignment, GfxRange* out_range); + ModResult (*push_uniform)(ModContext* ctx, const void* data, size_t size, GfxRange* out_range); + ModResult (*push_storage)(ModContext* ctx, const void* data, size_t size, GfxRange* out_range); + + ModResult (*register_stage_hook)(ModContext* ctx, GfxStage stage, const GfxStageHookDesc* desc, + GfxStageHookHandle* out_handle); + ModResult (*unregister_stage_hook)(ModContext* ctx, GfxStageHookHandle handle); + + ModResult (*resolve_pass)( + ModContext* ctx, const GfxResolveDesc* desc, GfxResolvedTargets* out_targets); + ModResult (*create_pass)(ModContext* ctx, uint32_t width, uint32_t height); +} GfxService; + +#ifdef __cplusplus +#include "mods/service.hpp" + +template <> +struct dusk::mods::ServiceTraits { + static constexpr const char* id = GFX_SERVICE_ID; + static constexpr uint16_t major_version = GFX_SERVICE_MAJOR; +}; +#endif diff --git a/src/dusk/mods/svc/gfx.cpp b/src/dusk/mods/svc/gfx.cpp new file mode 100644 index 0000000000..7fa472454d --- /dev/null +++ b/src/dusk/mods/svc/gfx.cpp @@ -0,0 +1,846 @@ +#include "registry.hpp" + +#include "aurora/lib/logging.hpp" +#include "dusk/gfx.hpp" +#include "dusk/mods/loader/loader.hpp" +#include "mods/svc/gfx.h" + +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace dusk::mods { +namespace { + +aurora::Module Log("dusk::mods::gfx"); + +enum class GfxSlotKind : uint8_t { + DrawType, + StageHook, + ComputeType, +}; + +enum class GfxStreamBuffer : uint8_t { + Verts, + Indices, + Uniform, + Storage, +}; + +struct GfxSlot { + GfxSlotKind kind = GfxSlotKind::DrawType; + uint32_t generation = 1; + bool alive = false; + LoadedMod* owner = nullptr; + ModContext* ownerContext = nullptr; + std::string ownerId; + void* userData = nullptr; + + GfxDrawFn drawFn = nullptr; + aurora::gfx::DrawTypeId auroraDrawId = aurora::gfx::InvalidDrawType; + + GfxStageFn stageFn = nullptr; + GfxStage stage = GFX_STAGE_SCENE_AFTER_TERRAIN; + + GfxComputeFn computeFn = nullptr; + aurora::gfx::EncoderTaskId auroraTaskId = aurora::gfx::InvalidEncoderTask; +}; + +struct WorkerFailure { + std::string modId; + std::string message; + std::vector drawIds; + std::vector taskIds; +}; + +std::mutex s_mutex; +std::vector s_slots; +std::vector s_freeSlots; +std::vector s_workerFailures; +bool s_modOffscreenOpen = false; + +constexpr uint64_t make_handle(uint32_t index, uint32_t generation) { + return static_cast(generation) << 32 | index; +} + +GfxSlot* resolve_slot_locked(uint64_t handle, GfxSlotKind kind) { + const auto index = static_cast(handle & 0xFFFFFFFFu); + const auto generation = static_cast(handle >> 32); + if (handle == 0 || index >= s_slots.size()) { + return nullptr; + } + auto& slot = s_slots[index]; + if (!slot.alive || slot.generation != generation || slot.kind != kind) { + return nullptr; + } + return &slot; +} + +GfxSlot* resolve_owned_slot_locked(LoadedMod& mod, uint64_t handle, GfxSlotKind kind) { + auto* slot = resolve_slot_locked(handle, kind); + if (slot == nullptr || slot->owner != &mod) { + return nullptr; + } + return slot; +} + +uint32_t alloc_slot_locked() { + if (!s_freeSlots.empty()) { + const auto index = s_freeSlots.back(); + s_freeSlots.pop_back(); + return index; + } + const auto index = static_cast(s_slots.size()); + s_slots.emplace_back(); + return index; +} + +void free_slot_locked(uint32_t index) { + auto& slot = s_slots[index]; + slot.alive = false; + ++slot.generation; + slot.owner = nullptr; + slot.ownerContext = nullptr; + slot.ownerId.clear(); + slot.userData = nullptr; + slot.drawFn = nullptr; + slot.auroraDrawId = aurora::gfx::InvalidDrawType; + slot.stageFn = nullptr; + slot.stage = GFX_STAGE_SCENE_AFTER_TERRAIN; + slot.computeFn = nullptr; + slot.auroraTaskId = aurora::gfx::InvalidEncoderTask; + s_freeSlots.push_back(index); +} + +void collect_mod_slots_locked(LoadedMod* owner, std::vector& drawIds, + std::vector& taskIds) { + for (uint32_t i = 0; i < s_slots.size(); ++i) { + auto& slot = s_slots[i]; + if (!slot.alive || slot.owner != owner) { + continue; + } + if (slot.kind == GfxSlotKind::DrawType && slot.auroraDrawId != aurora::gfx::InvalidDrawType) + { + drawIds.push_back(slot.auroraDrawId); + } else if (slot.kind == GfxSlotKind::ComputeType && + slot.auroraTaskId != aurora::gfx::InvalidEncoderTask) + { + taskIds.push_back(slot.auroraTaskId); + } + free_slot_locked(i); + } +} + +void unregister_aurora_types(const std::vector& drawIds, + const std::vector& taskIds) { + for (const auto id : drawIds) { + aurora::gfx::unregister_draw_type(id); + } + for (const auto id : taskIds) { + aurora::gfx::unregister_encoder_task_type(id); + } +} + +void draw_trampoline(const aurora::gfx::DrawContext& ctx, const wgpu::RenderPassEncoder& pass, + const void* payload, size_t payloadSize, void* userdata) { + const auto handle = static_cast(reinterpret_cast(userdata)); + GfxDrawFn fn = nullptr; + void* userData = nullptr; + ModContext* modContext = nullptr; + LoadedMod* owner = nullptr; + std::string ownerId; + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_slot_locked(handle, GfxSlotKind::DrawType); + if (slot == nullptr) { + return; + } + fn = slot->drawFn; + userData = slot->userData; + modContext = slot->ownerContext; + owner = slot->owner; + ownerId = slot->ownerId; + } + + GfxDrawContext drawContext{ + .struct_size = sizeof(GfxDrawContext), + .device = ctx.device.Get(), + .queue = ctx.queue.Get(), + .pass = pass.Get(), + .vertex_buffer = ctx.vertexBuffer.Get(), + .index_buffer = ctx.indexBuffer.Get(), + .uniform_buffer = ctx.uniformBuffer.Get(), + .storage_buffer = ctx.storageBuffer.Get(), + .color_format = static_cast(ctx.colorFormat), + .depth_format = static_cast(ctx.depthFormat), + .sample_count = ctx.sampleCount, + .target_width = ctx.targetWidth, + .target_height = ctx.targetHeight, + .uses_reversed_z = aurora::gfx::uses_reversed_z(), + }; + + std::string failure; + try { + fn(modContext, &drawContext, payload, payloadSize, userData); + return; + } catch (const std::exception& e) { + failure = fmt::format("exception in gfx draw callback: {}", e.what()); + } catch (...) { + failure = "unknown exception in gfx draw callback"; + } + + std::lock_guard lock{s_mutex}; + WorkerFailure record{ + .modId = std::move(ownerId), + .message = std::move(failure), + }; + collect_mod_slots_locked(owner, record.drawIds, record.taskIds); + s_workerFailures.push_back(std::move(record)); +} + +void compute_trampoline(const aurora::gfx::EncoderTaskContext& ctx, const wgpu::CommandEncoder& cmd, + const void* payload, size_t payloadSize, void* userdata) { + const auto handle = static_cast(reinterpret_cast(userdata)); + GfxComputeFn fn = nullptr; + void* userData = nullptr; + ModContext* modContext = nullptr; + LoadedMod* owner = nullptr; + std::string ownerId; + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_slot_locked(handle, GfxSlotKind::ComputeType); + if (slot == nullptr) { + return; + } + fn = slot->computeFn; + userData = slot->userData; + modContext = slot->ownerContext; + owner = slot->owner; + ownerId = slot->ownerId; + } + + GfxComputeContext computeContext{ + .struct_size = sizeof(GfxComputeContext), + .device = ctx.device.Get(), + .queue = ctx.queue.Get(), + .encoder = cmd.Get(), + .vertex_buffer = ctx.vertexBuffer.Get(), + .index_buffer = ctx.indexBuffer.Get(), + .uniform_buffer = ctx.uniformBuffer.Get(), + .storage_buffer = ctx.storageBuffer.Get(), + }; + + std::string failure; + try { + fn(modContext, &computeContext, payload, payloadSize, userData); + return; + } catch (const std::exception& e) { + failure = fmt::format("exception in gfx compute callback: {}", e.what()); + } catch (...) { + failure = "unknown exception in gfx compute callback"; + } + + std::lock_guard lock{s_mutex}; + WorkerFailure record{ + .modId = std::move(ownerId), + .message = std::move(failure), + }; + collect_mod_slots_locked(owner, record.drawIds, record.taskIds); + s_workerFailures.push_back(std::move(record)); +} + +} // namespace + +ModResult gfx_register_draw_type( + LoadedMod& mod, const char* label, GfxDrawFn draw, void* userData, uint64_t& outHandle) { + outHandle = 0; + + uint64_t handle = 0; + { + std::lock_guard lock{s_mutex}; + const auto index = alloc_slot_locked(); + auto& slot = s_slots[index]; + slot.kind = GfxSlotKind::DrawType; + slot.alive = true; + slot.owner = &mod; + slot.ownerContext = mod.context.get(); + slot.ownerId = mod.metadata.id; + slot.userData = userData; + slot.drawFn = draw; + handle = make_handle(index, slot.generation); + } + + const auto auroraId = aurora::gfx::register_draw_type(aurora::gfx::DrawTypeDescriptor{ + .label = label, + .draw = draw_trampoline, + .userdata = reinterpret_cast(static_cast(handle)), + }); + if (auroraId == aurora::gfx::InvalidDrawType) { + std::lock_guard lock{s_mutex}; + if (resolve_owned_slot_locked(mod, handle, GfxSlotKind::DrawType) != nullptr) { + free_slot_locked(static_cast(handle & 0xFFFFFFFFu)); + } + return MOD_ERROR; + } + + { + std::lock_guard lock{s_mutex}; + if (auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::DrawType)) { + slot->auroraDrawId = auroraId; + } + } + outHandle = handle; + return MOD_OK; +} + +ModResult gfx_unregister_draw_type(LoadedMod& mod, uint64_t handle) { + aurora::gfx::DrawTypeId auroraId = aurora::gfx::InvalidDrawType; + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::DrawType); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auroraId = slot->auroraDrawId; + free_slot_locked(static_cast(handle & 0xFFFFFFFFu)); + } + aurora::gfx::unregister_draw_type(auroraId); + return MOD_OK; +} + +ModResult gfx_push_draw(LoadedMod& mod, uint64_t handle, const void* payload, size_t payloadSize) { + aurora::gfx::DrawTypeId auroraId = aurora::gfx::InvalidDrawType; + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::DrawType); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auroraId = slot->auroraDrawId; + } + if (!aurora::gfx::push_custom_draw(auroraId, payload, payloadSize)) { + return MOD_UNAVAILABLE; + } + return MOD_OK; +} + +ModResult gfx_push_stream( + GfxStreamBuffer buffer, const void* data, size_t size, size_t alignment, GfxRange& outRange) { + aurora::gfx::Range range; + const auto* bytes = static_cast(data); + switch (buffer) { + case GfxStreamBuffer::Verts: + range = aurora::gfx::push_verts(bytes, size, alignment); + break; + case GfxStreamBuffer::Indices: + range = aurora::gfx::push_indices(bytes, size, alignment); + break; + case GfxStreamBuffer::Uniform: + range = aurora::gfx::push_uniform(bytes, size); + break; + case GfxStreamBuffer::Storage: + range = aurora::gfx::push_storage(bytes, size); + break; + } + if (range.size == 0) { + return MOD_UNAVAILABLE; + } + outRange = GfxRange{.offset = range.offset, .size = range.size}; + return MOD_OK; +} + +ModResult gfx_register_stage_hook( + LoadedMod& mod, GfxStage stage, GfxStageFn callback, void* userData, uint64_t& outHandle) { + outHandle = 0; + std::lock_guard lock{s_mutex}; + const auto index = alloc_slot_locked(); + auto& slot = s_slots[index]; + slot.kind = GfxSlotKind::StageHook; + slot.alive = true; + slot.owner = &mod; + slot.ownerContext = mod.context.get(); + slot.ownerId = mod.metadata.id; + slot.userData = userData; + slot.stageFn = callback; + slot.stage = stage; + outHandle = make_handle(index, slot.generation); + return MOD_OK; +} + +ModResult gfx_unregister_stage_hook(LoadedMod& mod, uint64_t handle) { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::StageHook); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + free_slot_locked(static_cast(handle & 0xFFFFFFFFu)); + return MOD_OK; +} + +ModResult gfx_resolve_pass(LoadedMod& mod, const GfxResolveDesc& desc, GfxResolvedTargets& out) { + out = GfxResolvedTargets{.struct_size = sizeof(GfxResolvedTargets)}; + if (aurora::gfx::is_offscreen() && !s_modOffscreenOpen) { + Log.error( + "[{}] resolve_pass: the active offscreen pass belongs to the game", mod.metadata.id); + return MOD_UNAVAILABLE; + } + const bool closesModOffscreen = s_modOffscreenOpen; + + aurora::gfx::ResolvedTargets resolved; + if (!aurora::gfx::resolve_pass( + aurora::gfx::ResolveDesc{.color = desc.color, .depth = desc.depth}, resolved)) + { + return MOD_UNAVAILABLE; + } + if (closesModOffscreen) { + s_modOffscreenOpen = false; + } + + out.color = resolved.color.Get(); + out.depth = resolved.depth.Get(); + out.color_format = static_cast(resolved.colorFormat); + out.width = resolved.width; + out.height = resolved.height; + return MOD_OK; +} + +ModResult gfx_create_pass(LoadedMod& mod, uint32_t width, uint32_t height) { + if (aurora::gfx::is_offscreen()) { + Log.error("[{}] create_pass: an offscreen pass is already active", mod.metadata.id); + return MOD_UNAVAILABLE; + } + if (!aurora::gfx::create_pass(width, height)) { + return MOD_UNAVAILABLE; + } + s_modOffscreenOpen = true; + return MOD_OK; +} + +ModResult gfx_register_compute_type( + LoadedMod& mod, const char* label, GfxComputeFn callback, void* userData, uint64_t& outHandle) { + outHandle = 0; + + uint64_t handle = 0; + { + std::lock_guard lock{s_mutex}; + const auto index = alloc_slot_locked(); + auto& slot = s_slots[index]; + slot.kind = GfxSlotKind::ComputeType; + slot.alive = true; + slot.owner = &mod; + slot.ownerContext = mod.context.get(); + slot.ownerId = mod.metadata.id; + slot.userData = userData; + slot.computeFn = callback; + handle = make_handle(index, slot.generation); + } + + const auto auroraId = + aurora::gfx::register_encoder_task_type(aurora::gfx::EncoderTaskDescriptor{ + .label = label, + .callback = compute_trampoline, + .userdata = reinterpret_cast(static_cast(handle)), + }); + if (auroraId == aurora::gfx::InvalidEncoderTask) { + std::lock_guard lock{s_mutex}; + if (resolve_owned_slot_locked(mod, handle, GfxSlotKind::ComputeType) != nullptr) { + free_slot_locked(static_cast(handle & 0xFFFFFFFFu)); + } + return MOD_ERROR; + } + + { + std::lock_guard lock{s_mutex}; + if (auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::ComputeType)) { + slot->auroraTaskId = auroraId; + } + } + outHandle = handle; + return MOD_OK; +} + +ModResult gfx_unregister_compute_type(LoadedMod& mod, uint64_t handle) { + aurora::gfx::EncoderTaskId auroraId = aurora::gfx::InvalidEncoderTask; + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::ComputeType); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auroraId = slot->auroraTaskId; + free_slot_locked(static_cast(handle & 0xFFFFFFFFu)); + } + aurora::gfx::unregister_encoder_task_type(auroraId); + return MOD_OK; +} + +ModResult gfx_push_compute( + LoadedMod& mod, uint64_t handle, const void* payload, size_t payloadSize) { + aurora::gfx::EncoderTaskId auroraId = aurora::gfx::InvalidEncoderTask; + { + std::lock_guard lock{s_mutex}; + auto* slot = resolve_owned_slot_locked(mod, handle, GfxSlotKind::ComputeType); + if (slot == nullptr) { + return MOD_INVALID_ARGUMENT; + } + auroraId = slot->auroraTaskId; + } + if (!aurora::gfx::push_encoder_task(auroraId, payload, payloadSize)) { + return MOD_UNAVAILABLE; + } + return MOD_OK; +} + +void gfx_run_stage( + GfxStage stage, const view_class* gameView, const view_port_class* gameViewport) { + struct StageEntry { + uint64_t handle; + GfxStageFn fn; + void* userData; + ModContext* context; + LoadedMod* owner; + }; + + std::vector entries; + { + std::lock_guard lock{s_mutex}; + for (uint32_t i = 0; i < s_slots.size(); ++i) { + const auto& slot = s_slots[i]; + if (slot.alive && slot.kind == GfxSlotKind::StageHook && slot.stage == stage) { + entries.push_back(StageEntry{ + .handle = make_handle(i, slot.generation), + .fn = slot.stageFn, + .userData = slot.userData, + .context = slot.ownerContext, + .owner = slot.owner, + }); + } + } + } + if (entries.empty()) { + return; + } + + const GfxStageContext stageContext{ + .struct_size = sizeof(GfxStageContext), + .stage = stage, + .game_view = gameView, + .game_viewport = gameViewport, + }; + + for (const auto& entry : entries) { + { + std::lock_guard lock{s_mutex}; + if (resolve_slot_locked(entry.handle, GfxSlotKind::StageHook) == nullptr) { + continue; + } + } + if (!entry.owner->active) { + continue; + } + + const bool wasOffscreen = aurora::gfx::is_offscreen(); + try { + entry.fn(entry.context, &stageContext, entry.userData); + } catch (const std::exception& e) { + fail_mod(*entry.owner, MOD_ERROR, + fmt::format("exception in gfx stage callback: {}", e.what())); + } catch (...) { + fail_mod(*entry.owner, MOD_ERROR, "unknown exception in gfx stage callback"); + } + + if (aurora::gfx::is_offscreen() != wasOffscreen) { + aurora::gfx::ResolvedTargets discarded; + aurora::gfx::resolve_pass( + aurora::gfx::ResolveDesc{.color = false, .depth = false}, discarded); + s_modOffscreenOpen = false; + fail_mod(*entry.owner, MOD_ERROR, + "gfx stage callback returned with its offscreen pass still open"); + } + } +} + +void gfx_drain_worker_failures() { + std::vector failures; + { + std::lock_guard lock{s_mutex}; + failures.swap(s_workerFailures); + } + if (failures.empty()) { + return; + } + + bool needsSynchronize = false; + for (const auto& failure : failures) { + unregister_aurora_types(failure.drawIds, failure.taskIds); + needsSynchronize = needsSynchronize || !failure.drawIds.empty() || !failure.taskIds.empty(); + } + if (needsSynchronize) { + aurora::gfx::synchronize(); + } + + for (const auto& failure : failures) { + for (auto& mod : ModLoader::instance().mods()) { + if (mod.metadata.id == failure.modId && mod.active) { + fail_mod(mod, MOD_ERROR, failure.message); + break; + } + } + } +} + +void gfx_remove_mod(LoadedMod& mod) { + std::vector drawIds; + std::vector taskIds; + { + std::lock_guard lock{s_mutex}; + collect_mod_slots_locked(&mod, drawIds, taskIds); + } + if (drawIds.empty() && taskIds.empty()) { + return; + } + unregister_aurora_types(drawIds, taskIds); + aurora::gfx::synchronize(); +} + +} // namespace dusk::mods + +namespace dusk::mods::svc { +namespace { + +ModResult gfx_get_device_info(ModContext* context, GfxDeviceInfo* outInfo) { + if (outInfo == nullptr || outInfo->struct_size < sizeof(GfxDeviceInfo)) { + return MOD_INVALID_ARGUMENT; + } + const uint32_t structSize = outInfo->struct_size; + *outInfo = GfxDeviceInfo{.struct_size = structSize}; + + auto* mod = mod_from_context(context); + if (mod == nullptr) { + return MOD_INVALID_ARGUMENT; + } + + outInfo->device = aurora::gfx::device().Get(); + outInfo->queue = aurora::gfx::queue().Get(); + outInfo->color_format = static_cast(aurora::gfx::color_format()); + outInfo->depth_format = static_cast(aurora::gfx::depth_format()); + outInfo->sample_count = aurora::gfx::sample_count(); + outInfo->uses_reversed_z = aurora::gfx::uses_reversed_z(); + return MOD_OK; +} + +void* gfx_get_proc_address(ModContext* context, const char* name) { + if (mod_from_context(context) == nullptr || name == nullptr) { + return nullptr; + } + return reinterpret_cast(wgpuGetProcAddress(WGPUStringView{name, WGPU_STRLEN})); +} + +ModResult gfx_register_draw_type_impl( + ModContext* context, const GfxDrawTypeDesc* desc, GfxDrawTypeHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(GfxDrawTypeDesc) || + desc->draw == nullptr || outHandle == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = + gfx_register_draw_type(*mod, desc->label, desc->draw, desc->user_data, handle); + if (result != MOD_OK) { + return result; + } + *outHandle = handle; + return MOD_OK; +} + +ModResult gfx_unregister_draw_type_impl(ModContext* context, GfxDrawTypeHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + return gfx_unregister_draw_type(*mod, handle); +} + +ModResult gfx_push_draw_impl( + ModContext* context, GfxDrawTypeHandle handle, const void* payload, size_t payloadSize) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0 || payloadSize > GFX_INLINE_DRAW_PAYLOAD_SIZE || + (payloadSize > 0 && payload == nullptr)) + { + return MOD_INVALID_ARGUMENT; + } + return gfx_push_draw(*mod, handle, payload, payloadSize); +} + +ModResult gfx_push_stream_impl(ModContext* context, GfxStreamBuffer buffer, const void* data, + size_t size, size_t alignment, GfxRange* outRange) { + if (outRange != nullptr) { + *outRange = GfxRange{0, 0}; + } + if (mod_from_context(context) == nullptr || data == nullptr || size == 0 || outRange == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + return gfx_push_stream(buffer, data, size, alignment, *outRange); +} + +ModResult gfx_push_verts_impl( + ModContext* context, const void* data, size_t size, size_t alignment, GfxRange* outRange) { + return gfx_push_stream_impl(context, GfxStreamBuffer::Verts, data, size, alignment, outRange); +} + +ModResult gfx_push_indices_impl( + ModContext* context, const void* data, size_t size, size_t alignment, GfxRange* outRange) { + return gfx_push_stream_impl(context, GfxStreamBuffer::Indices, data, size, alignment, outRange); +} + +ModResult gfx_push_uniform_impl( + ModContext* context, const void* data, size_t size, GfxRange* outRange) { + return gfx_push_stream_impl(context, GfxStreamBuffer::Uniform, data, size, 0, outRange); +} + +ModResult gfx_push_storage_impl( + ModContext* context, const void* data, size_t size, GfxRange* outRange) { + return gfx_push_stream_impl(context, GfxStreamBuffer::Storage, data, size, 0, outRange); +} + +bool valid_stage(GfxStage stage) { + return stage == GFX_STAGE_SCENE_AFTER_TERRAIN || stage == GFX_STAGE_FRAME_BEFORE_HUD || + stage == GFX_STAGE_FRAME_AFTER_HUD || stage == GFX_STAGE_SCENE_BEGIN || + stage == GFX_STAGE_SCENE_AFTER_OPAQUE; +} + +ModResult gfx_register_stage_hook_impl(ModContext* context, GfxStage stage, + const GfxStageHookDesc* desc, GfxStageHookHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(GfxStageHookDesc) || + desc->callback == nullptr || outHandle == nullptr || !valid_stage(stage)) + { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = + gfx_register_stage_hook(*mod, stage, desc->callback, desc->user_data, handle); + if (result != MOD_OK) { + return result; + } + *outHandle = handle; + return MOD_OK; +} + +ModResult gfx_unregister_stage_hook_impl(ModContext* context, GfxStageHookHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + return gfx_unregister_stage_hook(*mod, handle); +} + +ModResult gfx_resolve_pass_impl( + ModContext* context, const GfxResolveDesc* desc, GfxResolvedTargets* outTargets) { + if (outTargets != nullptr && outTargets->struct_size >= sizeof(GfxResolvedTargets)) { + *outTargets = GfxResolvedTargets{.struct_size = sizeof(GfxResolvedTargets)}; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(GfxResolveDesc) || + outTargets == nullptr || outTargets->struct_size < sizeof(GfxResolvedTargets) || + (!desc->color && !desc->depth)) + { + return MOD_INVALID_ARGUMENT; + } + return gfx_resolve_pass(*mod, *desc, *outTargets); +} + +ModResult gfx_create_pass_impl(ModContext* context, uint32_t width, uint32_t height) { + auto* mod = mod_from_context(context); + if (mod == nullptr || width == 0 || height == 0) { + return MOD_INVALID_ARGUMENT; + } + return gfx_create_pass(*mod, width, height); +} + +ModResult gfx_register_compute_type_impl( + ModContext* context, const GfxComputeTypeDesc* desc, GfxComputeTypeHandle* outHandle) { + if (outHandle != nullptr) { + *outHandle = 0; + } + auto* mod = mod_from_context(context); + if (mod == nullptr || desc == nullptr || desc->struct_size < sizeof(GfxComputeTypeDesc) || + desc->callback == nullptr || outHandle == nullptr) + { + return MOD_INVALID_ARGUMENT; + } + uint64_t handle = 0; + const auto result = + gfx_register_compute_type(*mod, desc->label, desc->callback, desc->user_data, handle); + if (result != MOD_OK) { + return result; + } + *outHandle = handle; + return MOD_OK; +} + +ModResult gfx_unregister_compute_type_impl(ModContext* context, GfxComputeTypeHandle handle) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0) { + return MOD_INVALID_ARGUMENT; + } + return gfx_unregister_compute_type(*mod, handle); +} + +ModResult gfx_push_compute_impl( + ModContext* context, GfxComputeTypeHandle handle, const void* payload, size_t payloadSize) { + auto* mod = mod_from_context(context); + if (mod == nullptr || handle == 0 || payloadSize > GFX_INLINE_DRAW_PAYLOAD_SIZE || + (payloadSize > 0 && payload == nullptr)) + { + return MOD_INVALID_ARGUMENT; + } + return gfx_push_compute(*mod, handle, payload, payloadSize); +} + +constexpr GfxService s_gfxService{ + .header = SERVICE_HEADER(GfxService, GFX_SERVICE_MAJOR, GFX_SERVICE_MINOR), + .get_device_info = gfx_get_device_info, + .get_proc_address = gfx_get_proc_address, + .register_draw_type = gfx_register_draw_type_impl, + .unregister_draw_type = gfx_unregister_draw_type_impl, + .push_draw = gfx_push_draw_impl, + .register_compute_type = gfx_register_compute_type_impl, + .unregister_compute_type = gfx_unregister_compute_type_impl, + .push_compute = gfx_push_compute_impl, + .push_verts = gfx_push_verts_impl, + .push_indices = gfx_push_indices_impl, + .push_uniform = gfx_push_uniform_impl, + .push_storage = gfx_push_storage_impl, + .register_stage_hook = gfx_register_stage_hook_impl, + .unregister_stage_hook = gfx_unregister_stage_hook_impl, + .resolve_pass = gfx_resolve_pass_impl, + .create_pass = gfx_create_pass_impl, +}; + +} // namespace + +constinit const ServiceModule g_gfxModule{ + .id = GFX_SERVICE_ID, + .majorVersion = GFX_SERVICE_MAJOR, + .minorVersion = GFX_SERVICE_MINOR, + .service = &s_gfxService, + .modDetached = gfx_remove_mod, + .frameBegin = gfx_drain_worker_failures, +}; + +} // namespace dusk::mods::svc diff --git a/src/dusk/mods/svc/registry.cpp b/src/dusk/mods/svc/registry.cpp index f9e7f96476..338625230a 100644 --- a/src/dusk/mods/svc/registry.cpp +++ b/src/dusk/mods/svc/registry.cpp @@ -209,6 +209,7 @@ void ModLoader::init_services() { &svc::g_uiModule, &svc::g_gameModule, &svc::g_cameraModule, + &svc::g_gfxModule, }) { svc::register_module(*module); diff --git a/src/dusk/mods/svc/registry.hpp b/src/dusk/mods/svc/registry.hpp index b4b76cd510..191626e0ef 100644 --- a/src/dusk/mods/svc/registry.hpp +++ b/src/dusk/mods/svc/registry.hpp @@ -71,5 +71,6 @@ extern const ServiceModule g_configModule; extern const ServiceModule g_uiModule; extern const ServiceModule g_gameModule; extern const ServiceModule g_cameraModule; +extern const ServiceModule g_gfxModule; } // namespace dusk::mods::svc diff --git a/src/m_Do/m_Do_graphic.cpp b/src/m_Do/m_Do_graphic.cpp index caf823b065..e7a5bf9b54 100644 --- a/src/m_Do/m_Do_graphic.cpp +++ b/src/m_Do/m_Do_graphic.cpp @@ -53,6 +53,7 @@ #include "dusk/dusk.h" #include "dusk/endian.h" #include "dusk/frame_interpolation.h" +#include "dusk/gfx.hpp" #include "dusk/gx_helper.h" #include "dusk/imgui/ImGuiConsole.hpp" #include "dusk/logging.h" @@ -2356,6 +2357,10 @@ int mDoGph_Painter() { GXSetClipMode(GX_CLIP_ENABLE); +#if TARGET_PC + dusk::mods::gfx_run_stage(GFX_STAGE_SCENE_BEGIN, &camera_p->view, view_port); +#endif + #if DEBUG // "drawing up to Background (Translucent) (Rendering)" fapGm_HIO_c::stopCpuTimer("背景(半透明)描画まで(レンダリング)"); @@ -2384,6 +2389,10 @@ int mDoGph_Painter() { GX_DEBUG_GROUP(dComIfGd_drawShadow, camera_p->view.viewMtx); +#if TARGET_PC + dusk::mods::gfx_run_stage(GFX_STAGE_SCENE_AFTER_TERRAIN, &camera_p->view, view_port); +#endif + #if DEBUG // "shadow drawing (Rendering)" fapGm_HIO_c::stopCpuTimer("影描画(レンダリング)"); @@ -2413,6 +2422,10 @@ int mDoGph_Painter() { GX_DEBUG_GROUP(dComIfGd_drawOpaListPacket); +#if TARGET_PC + dusk::mods::gfx_run_stage(GFX_STAGE_SCENE_AFTER_OPAQUE, &camera_p->view, view_port); +#endif + #if DEBUG // "drawing up to special-use drawing (Opaque) except J3D (Rendering)" fapGm_HIO_c::stopCpuTimer("J3D以外などの特殊用(不透明)描画まで(レンダリング)"); @@ -2778,6 +2791,10 @@ int mDoGph_Painter() { captureScreenSetPort(); #endif +#if TARGET_PC + dusk::mods::gfx_run_stage(GFX_STAGE_FRAME_BEFORE_HUD); +#endif + if (fapGmHIO_get2Ddraw()) { Mtx m4; cMtx_copy(j3dSys.getViewMtx(), m4); @@ -2835,6 +2852,10 @@ int mDoGph_Painter() { dComIfGd_draw2DXlu(); } +#if TARGET_PC + dusk::mods::gfx_run_stage(GFX_STAGE_FRAME_AFTER_HUD); +#endif + #if DEBUG if (dJcame_c::get()) { dJcame_c::get()->show2D(); From 75a249f3e13858f6c70133763ff7c075c46a34bb Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 10 Jul 2026 00:40:18 -0600 Subject: [PATCH 03/10] GTAO demo mod --- CMakeLists.txt | 3 +- cmake/GameABIConfig.cmake | 5 + docs/modding.md | 2 +- extern/aurora | 2 +- mods/ao_mod/CMakeLists.txt | 20 + mods/ao_mod/mod.json | 7 + mods/ao_mod/res/composite.wgsl | 161 +++ mods/ao_mod/res/denoise.wgsl | 108 ++ mods/ao_mod/res/gtao.wgsl | 247 +++++ mods/ao_mod/res/licenses/BEVY-APACHE-2.0.txt | 176 ++++ mods/ao_mod/res/licenses/BEVY-MIT.txt | 19 + mods/ao_mod/res/licenses/XEGTAO-MIT.txt | 21 + mods/ao_mod/res/preprocess_depth.wgsl | 138 +++ mods/ao_mod/src/mod.cpp | 931 ++++++++++++++++++ .../template_mod}/CMakeLists.txt | 0 mods/template_mod/mod.json | 7 + .../template_mod}/res/.gitkeep | 0 .../template_mod}/src/mod.cpp | 0 sdk/CMakeLists.txt | 9 +- tools/mod_template/mod.json | 7 - 20 files changed, 1851 insertions(+), 12 deletions(-) create mode 100644 mods/ao_mod/CMakeLists.txt create mode 100644 mods/ao_mod/mod.json create mode 100644 mods/ao_mod/res/composite.wgsl create mode 100644 mods/ao_mod/res/denoise.wgsl create mode 100644 mods/ao_mod/res/gtao.wgsl create mode 100644 mods/ao_mod/res/licenses/BEVY-APACHE-2.0.txt create mode 100644 mods/ao_mod/res/licenses/BEVY-MIT.txt create mode 100644 mods/ao_mod/res/licenses/XEGTAO-MIT.txt create mode 100644 mods/ao_mod/res/preprocess_depth.wgsl create mode 100644 mods/ao_mod/src/mod.cpp rename {tools/mod_template => mods/template_mod}/CMakeLists.txt (100%) create mode 100644 mods/template_mod/mod.json rename {tools/mod_template => mods/template_mod}/res/.gitkeep (100%) rename {tools/mod_template => mods/template_mod}/src/mod.cpp (100%) delete mode 100644 tools/mod_template/mod.json diff --git a/CMakeLists.txt b/CMakeLists.txt index e62704fe1c..abf30f6863 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -559,7 +559,8 @@ include(cmake/ModSDK.cmake) 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(tools/mod_template) + add_subdirectory(mods/template_mod) + add_subdirectory(mods/ao_mod) endif () if (APPLE) diff --git a/cmake/GameABIConfig.cmake b/cmake/GameABIConfig.cmake index 030f1181a0..d48be445a2 100644 --- a/cmake/GameABIConfig.cmake +++ b/cmake/GameABIConfig.cmake @@ -25,3 +25,8 @@ set(_game_include_dirs add_library(dusklight_game_headers INTERFACE) target_include_directories(dusklight_game_headers INTERFACE ${_game_include_dirs}) target_compile_definitions(dusklight_game_headers INTERFACE ${_game_compile_defs}) +if (TARGET dawn::dawncpp_headers) + target_link_libraries(dusklight_game_headers INTERFACE dawn::dawncpp_headers) +elseif (TARGET dawn::webgpu_dawn) + target_link_libraries(dusklight_game_headers INTERFACE dawn::webgpu_dawn) +endif () diff --git a/docs/modding.md b/docs/modding.md index b2e7ba2e26..6f2d1a8580 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -28,7 +28,7 @@ function, read and write data fields, and hook the vast majority of game functio ## Getting Started -Fork the [mod template](../tools/mod_template/), a self-contained CMake project that uses the Dusklight mod SDK. +Fork the [mod template](../mods/template_mod/), a self-contained CMake project that uses the Dusklight mod SDK. ``` my_mod/ diff --git a/extern/aurora b/extern/aurora index 0a5a5d90ef..1dde08fa0d 160000 --- a/extern/aurora +++ b/extern/aurora @@ -1 +1 @@ -Subproject commit 0a5a5d90efd2dbc7b402ab61f1021922dbf7496f +Subproject commit 1dde08fa0d0030133788a6250a81c8b9c44f246f diff --git a/mods/ao_mod/CMakeLists.txt b/mods/ao_mod/CMakeLists.txt new file mode 100644 index 0000000000..13590923f0 --- /dev/null +++ b/mods/ao_mod/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.25) +project(ao_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(ao_mod + SOURCES src/mod.cpp + MOD_JSON mod.json + RES_DIR res + BUNDLE +) diff --git a/mods/ao_mod/mod.json b/mods/ao_mod/mod.json new file mode 100644 index 0000000000..9019cf335d --- /dev/null +++ b/mods/ao_mod/mod.json @@ -0,0 +1,7 @@ +{ + "id": "dev.twilitrealm.ao_mod", + "name": "[Demo] Ambient Occlusion", + "version": "1.0.0", + "author": "Twilit Realm", + "description": "Ground-truth ambient occlusion (GTAO) computed from the scene depth buffer and composited over the game. Ported from Bevy Engine's SSAO and Intel XeGTAO." +} diff --git a/mods/ao_mod/res/composite.wgsl b/mods/ao_mod/res/composite.wgsl new file mode 100644 index 0000000000..fa3a0101b8 --- /dev/null +++ b/mods/ao_mod/res/composite.wgsl @@ -0,0 +1,161 @@ +// Fullscreen composite: multiplies the denoised ambient-occlusion visibility over the scene. +// +// Debug views: +// 1 = raw AO visibility as grayscale +// 2 = view-space normals reconstructed from depth (keep in sync with gtao.wgsl) +// 3 = the preprocessed depth input +// 4 = depth staircase detector + +struct Uniforms { + projection: mat4x4f, + inverse_projection: mat4x4f, + size: vec2f, // AO texture size in pixels (may be half the render size) + inv_size: vec2f, + depth_scale: vec2f, + effect_radius: f32, + intensity: f32, + slice_count: f32, + samples_per_slice_side: f32, + debug_view: u32, + _pad: f32, +} + +@group(0) @binding(0) var ambient_occlusion: texture_2d; +@group(0) @binding(1) var preprocessed_depth: texture_2d; +@group(0) @binding(2) var scene_depth_raw: texture_2d; +@group(0) @binding(3) var uniforms: Uniforms; + +struct VertexOutput { + @builtin(position) position: vec4f, + @location(0) uv: vec2f, +} + +@vertex +fn vs_main(@builtin(vertex_index) index: u32) -> VertexOutput { + // Fullscreen triangle + 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; +} + +// Manual bilinear sample (r32float is unfilterable without optional device features) +fn sample_visibility(uv: vec2f) -> f32 { + let coordinates = uv * uniforms.size - 0.5; + let base = floor(coordinates); + let fraction = coordinates - base; + let max_coordinates = vec2i(uniforms.size) - 1i; + let p00 = clamp(vec2i(base), vec2i(0i), max_coordinates); + let p11 = clamp(vec2i(base) + 1i, vec2i(0i), max_coordinates); + let v00 = textureLoad(ambient_occlusion, vec2i(p00.x, p00.y), 0i).r; + let v10 = textureLoad(ambient_occlusion, vec2i(p11.x, p00.y), 0i).r; + let v01 = textureLoad(ambient_occlusion, vec2i(p00.x, p11.y), 0i).r; + let v11 = textureLoad(ambient_occlusion, vec2i(p11.x, p11.y), 0i).r; + let top = mix(v00, v10, fraction.x); + let bottom = mix(v01, v11, fraction.x); + return mix(top, bottom, fraction.y); +} + +fn load_depth(pixel_coordinates: vec2) -> f32 { + let coordinates = clamp(pixel_coordinates, vec2(0i), vec2(uniforms.size) - 1i); + return textureLoad(preprocessed_depth, coordinates, 0i).r; +} + +fn reconstruct_view_space_position(depth: f32, uv: vec2f) -> vec3f { + let clip_xy = vec2f(uv.x * 2.0 - 1.0, 1.0 - 2.0 * uv.y); + let t = uniforms.inverse_projection * vec4f(clip_xy, depth, 1.0); + return t.xyz / t.w; +} + +fn view_position_at(pixel_coordinates: vec2) -> vec3f { + let depth = load_depth(pixel_coordinates); + let uv = (vec2f(pixel_coordinates) + 0.5) * uniforms.inv_size; + return reconstruct_view_space_position(depth, uv); +} + +fn reconstruct_normal(pixel_coordinates: vec2, pixel_position: vec3f, depth_center: f32) -> vec3f { + let depth_left1 = load_depth(pixel_coordinates + vec2(-1i, 0i)); + let depth_left2 = load_depth(pixel_coordinates + vec2(-2i, 0i)); + let depth_right1 = load_depth(pixel_coordinates + vec2(1i, 0i)); + let depth_right2 = load_depth(pixel_coordinates + vec2(2i, 0i)); + let depth_top1 = load_depth(pixel_coordinates + vec2(0i, -1i)); + let depth_top2 = load_depth(pixel_coordinates + vec2(0i, -2i)); + let depth_bottom1 = load_depth(pixel_coordinates + vec2(0i, 1i)); + let depth_bottom2 = load_depth(pixel_coordinates + vec2(0i, 2i)); + + let use_left = abs(2.0 * depth_left1 - depth_left2 - depth_center) < + abs(2.0 * depth_right1 - depth_right2 - depth_center); + let use_top = abs(2.0 * depth_top1 - depth_top2 - depth_center) < + abs(2.0 * depth_bottom1 - depth_bottom2 - depth_center); + + var ddx: vec3f; + if use_left { + ddx = pixel_position - view_position_at(pixel_coordinates + vec2(-1i, 0i)); + } else { + ddx = view_position_at(pixel_coordinates + vec2(1i, 0i)) - pixel_position; + } + var ddy: vec3f; + if use_top { + ddy = pixel_position - view_position_at(pixel_coordinates + vec2(0i, -1i)); + } else { + ddy = view_position_at(pixel_coordinates + vec2(0i, 1i)) - pixel_position; + } + + var normal = normalize(cross(ddy, ddx)); + if dot(normal, pixel_position) > 0.0 { + normal = -normal; + } + return normal; +} + +// Raw-snapshot variant of load_depth for the staircase view +fn load_raw_depth(pixel_coordinates: vec2) -> f32 { + let size = vec2(textureDimensions(scene_depth_raw)); + let coordinates = clamp(pixel_coordinates, vec2(0i), size - 1i); + return textureLoad(scene_depth_raw, coordinates, 0i).r; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4f { + if uniforms.debug_view == 2u { + // Reconstructed view-space normals, [-1,1] -> RGB + let pixel = vec2(in.uv * uniforms.size); + let depth = load_depth(pixel); + let uv = (vec2f(pixel) + 0.5) * uniforms.inv_size; + let position = reconstruct_view_space_position(depth, uv); + let normal = reconstruct_normal(pixel, position, depth); + return vec4f(normal * 0.5 + 0.5, 1.0); + } + if uniforms.debug_view == 3u { + // Preprocessed depth as an exponential distance gradient (white = near, black = far) + let pixel = vec2(in.uv * uniforms.size); + let position = view_position_at(pixel); + let value = exp(-max(-position.z, 0.0) * 0.0003); + return vec4f(value, value, value, 1.0); + } + if uniforms.debug_view == 4u { + // Staircase detector on the raw snapshot depth + let size = vec2f(textureDimensions(scene_depth_raw)); + let pixel = vec2(in.uv * size); + let d_center = load_raw_depth(pixel); + let d_left = load_raw_depth(pixel + vec2(-1i, 0i)); + let d_right = load_raw_depth(pixel + vec2(1i, 0i)); + let d_top = load_raw_depth(pixel + vec2(0i, -1i)); + let d_bottom = load_raw_depth(pixel + vec2(0i, 1i)); + let gradient_x = abs(d_right - d_left) * 0.5; + let curvature_x = abs(d_right - 2.0 * d_center + d_left); + let gradient_y = abs(d_bottom - d_top) * 0.5; + let curvature_y = abs(d_bottom - 2.0 * d_center + d_top); + let ratio_x = curvature_x / max(gradient_x, 1e-12); + let ratio_y = curvature_y / max(gradient_y, 1e-12); + return vec4f(saturate(ratio_x), saturate(ratio_y), 0.0, 1.0); + } + + let visibility = sample_visibility(in.uv); + if uniforms.debug_view == 1u { + return vec4f(visibility, visibility, visibility, 1.0); + } + let value = mix(1.0, visibility, uniforms.intensity); + return vec4f(value, value, value, 1.0); +} diff --git a/mods/ao_mod/res/denoise.wgsl b/mods/ao_mod/res/denoise.wgsl new file mode 100644 index 0000000000..b947b1bd04 --- /dev/null +++ b/mods/ao_mod/res/denoise.wgsl @@ -0,0 +1,108 @@ +// 3x3 bilaterial filter (edge-preserving blur) +// https://people.csail.mit.edu/sparis/bf_course/course_notes.pdf +// +// Note: Does not use the Gaussian kernel part of a typical bilateral blur +// From the paper: "use the information gathered on a neighborhood of 4 x 4 using a bilateral filter for +// reconstruction, using _uniform_ convolution weights" +// +// Note: The paper does a 4x4 (not quite centered) filter, offset by +/- 1 pixel every other frame +// XeGTAO does a 3x3 filter, on two pixels at a time per compute thread, applied twice +// We do a 3x3 filter, on 1 pixel per compute thread, applied once +// +// Ported from Bevy Engine, crates/bevy_pbr/src/ssao/spatial_denoise.wgsl (v0.13.2), licensed +// MIT OR Apache-2.0 (see res/licenses/), itself derived from Intel XeGTAO (MIT). +// +// PORT: the textureGather calls are rewritten as explicit per-neighbor textureLoads (r32float +// and r32uint are unfilterable); Bevy view uniforms -> the mod's uniform block; r16float -> r32float. + +struct Uniforms { + projection: mat4x4f, + inverse_projection: mat4x4f, + size: vec2f, + inv_size: vec2f, + depth_scale: vec2f, + effect_radius: f32, + intensity: f32, + slice_count: f32, + samples_per_slice_side: f32, + debug_view: u32, + _pad: f32, +} + +@group(0) @binding(0) var ambient_occlusion_noisy: texture_2d; +@group(0) @binding(1) var depth_differences: texture_2d; +@group(0) @binding(2) var ambient_occlusion: texture_storage_2d; +@group(0) @binding(3) var uniforms: Uniforms; + +fn clamp_coordinates(pixel_coordinates: vec2) -> vec2 { + return clamp(pixel_coordinates, vec2(0i), vec2(uniforms.size) - 1i); +} + +// Each pixel's packed edge info is (left, right, top, bottom) weights, packed by the GTAO pass. +fn load_edges(pixel_coordinates: vec2) -> vec4 { + return unpack4x8unorm(textureLoad(depth_differences, clamp_coordinates(pixel_coordinates), 0i).r); +} + +fn load_visibility(pixel_coordinates: vec2) -> f32 { + return textureLoad(ambient_occlusion_noisy, clamp_coordinates(pixel_coordinates), 0i).r; +} + +@compute +@workgroup_size(8, 8, 1) +fn spatial_denoise(@builtin(global_invocation_id) global_id: vec3) { + let pixel_coordinates = vec2(global_id.xy); + + let left_edges = load_edges(pixel_coordinates + vec2(-1i, 0i)); + let right_edges = load_edges(pixel_coordinates + vec2(1i, 0i)); + let top_edges = load_edges(pixel_coordinates + vec2(0i, -1i)); + let bottom_edges = load_edges(pixel_coordinates + vec2(0i, 1i)); + var center_edges = load_edges(pixel_coordinates); + // Cross-check each edge against the neighbor's opposing edge weight. + center_edges *= vec4(left_edges.y, right_edges.x, top_edges.w, bottom_edges.z); + + let center_weight = 1.2; + let left_weight = center_edges.x; + let right_weight = center_edges.y; + let top_weight = center_edges.z; + let bottom_weight = center_edges.w; + let top_left_weight = 0.425 * (top_weight * top_edges.x + left_weight * left_edges.z); + let top_right_weight = 0.425 * (top_weight * top_edges.y + right_weight * right_edges.z); + let bottom_left_weight = 0.425 * (bottom_weight * bottom_edges.x + left_weight * left_edges.w); + let bottom_right_weight = 0.425 * (bottom_weight * bottom_edges.y + right_weight * right_edges.w); + + let center_visibility = load_visibility(pixel_coordinates); + let left_visibility = load_visibility(pixel_coordinates + vec2(-1i, 0i)); + let right_visibility = load_visibility(pixel_coordinates + vec2(1i, 0i)); + let top_visibility = load_visibility(pixel_coordinates + vec2(0i, -1i)); + let bottom_visibility = load_visibility(pixel_coordinates + vec2(0i, 1i)); + let top_left_visibility = load_visibility(pixel_coordinates + vec2(-1i, -1i)); + let top_right_visibility = load_visibility(pixel_coordinates + vec2(1i, -1i)); + let bottom_left_visibility = load_visibility(pixel_coordinates + vec2(-1i, 1i)); + let bottom_right_visibility = load_visibility(pixel_coordinates + vec2(1i, 1i)); + + // PORT: Bevy sums the center sample unweighted while still counting center_weight in the + // denominator; XeGTAO's original weights the value too, which is what we do here. + var sum = center_visibility * center_weight; + sum += left_visibility * left_weight; + sum += right_visibility * right_weight; + sum += top_visibility * top_weight; + sum += bottom_visibility * bottom_weight; + sum += top_left_visibility * top_left_weight; + sum += top_right_visibility * top_right_weight; + sum += bottom_left_visibility * bottom_left_weight; + sum += bottom_right_visibility * bottom_right_weight; + + var sum_weight = center_weight; + sum_weight += left_weight; + sum_weight += right_weight; + sum_weight += top_weight; + sum_weight += bottom_weight; + sum_weight += top_left_weight; + sum_weight += top_right_weight; + sum_weight += bottom_left_weight; + sum_weight += bottom_right_weight; + + let denoised_visibility = sum / sum_weight; + + textureStore(ambient_occlusion, pixel_coordinates, vec4(denoised_visibility, 0.0, 0.0, 0.0)); +} diff --git a/mods/ao_mod/res/gtao.wgsl b/mods/ao_mod/res/gtao.wgsl new file mode 100644 index 0000000000..ee23774681 --- /dev/null +++ b/mods/ao_mod/res/gtao.wgsl @@ -0,0 +1,247 @@ +// Ground Truth-based Ambient Occlusion (GTAO) +// Paper: https://www.activision.com/cdn/research/Practical_Real_Time_Strategies_for_Accurate_Indirect_Occlusion_NEW%20VERSION_COLOR.pdf +// Presentation: https://blog.selfshadow.com/publications/s2016-shading-course/activision/s2016_pbs_activision_occlusion.pdf +// +// Ported from Bevy Engine, crates/bevy_pbr/src/ssao/gtao.wgsl (v0.13.2), licensed +// MIT OR Apache-2.0 (see res/licenses/), itself heavily based on XeGTAO v1.30 from Intel (MIT): +// https://github.com/GameTechDev/XeGTAO/blob/0d177ce06bfa642f64d8af4de1197ad1bcb862d4/Source/Rendering/Shaders/XeGTAO.hlsli +// +// PORT: +// - Bevy view/globals bindings -> the mod's own uniform block (matrices from Dusklight's +// CameraService, WebGPU clip convention, reversed-Z - the same convention Bevy uses). +// - Prepass normals -> normals reconstructed from depth (atyuwen's accurate 5-tap method, +// https://atyuwen.github.io/posts/normal-reconstruction/). +// - Sampler-based reads -> textureLoad (r32float is unfilterable without optional features); +// the mip level for the XeGTAO bandwidth optimization is selected explicitly per load. +// - effect_radius and slice/sample counts come from uniforms instead of constants/shader defs +// (game world units are ~100x larger than Bevy's meters, and quality is a live setting). +// - No TEMPORAL_JITTER: the noise index is pinned (no TAA; the spatial denoiser is the only +// filter, a configuration XeGTAO supports). +// - Storage format r16float -> r32float (core WebGPU storage format). + +struct Uniforms { + projection: mat4x4f, + inverse_projection: mat4x4f, + size: vec2f, + inv_size: vec2f, + depth_scale: vec2f, + effect_radius: f32, + intensity: f32, + slice_count: f32, + samples_per_slice_side: f32, + debug_view: u32, + _pad: f32, +} + +@group(0) @binding(0) var preprocessed_depth: texture_2d; +@group(0) @binding(1) var hilbert_index_lut: texture_2d; +@group(0) @binding(2) var ambient_occlusion: texture_storage_2d; +@group(0) @binding(3) var depth_differences: texture_storage_2d; +@group(0) @binding(4) var uniforms: Uniforms; + +const PI: f32 = 3.141592653589793; +const HALF_PI: f32 = 1.5707963267948966; + +fn fast_sqrt(x: f32) -> f32 { + return bitcast(0x1fbd1df5 + (bitcast(x) >> 1u)); +} + +fn fast_acos(in_x: f32) -> f32 { + let x = abs(in_x); + var res = -0.156583 * x + HALF_PI; + res *= fast_sqrt(1.0 - x); + return select(PI - res, res, in_x >= 0.0); +} + +fn load_noise(pixel_coordinates: vec2) -> vec2 { + let index = textureLoad(hilbert_index_lut, pixel_coordinates % 64, 0).r; + // R2 sequence - http://extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences + return fract(0.5 + f32(index) * vec2(0.75487766624669276005, 0.5698402909980532659114)); +} + +fn load_depth(pixel_coordinates: vec2, mip_level: i32) -> f32 { + let mip_size = max(vec2(uniforms.size) >> vec2(u32(mip_level)), vec2(1i)); + let coordinates = clamp(pixel_coordinates, vec2(0i), mip_size - 1i); + return textureLoad(preprocessed_depth, coordinates, mip_level).r; +} + +// Calculate differences in depth between neighbor pixels (later used by the spatial denoiser pass to preserve object edges) +fn calculate_neighboring_depth_differences(pixel_coordinates: vec2) -> f32 { + // Sample the pixel's depth and 4 depths around it + // PORT: explicit loads instead of two textureGathers. + let depth_center = load_depth(pixel_coordinates, 0i); + let depth_left = load_depth(pixel_coordinates + vec2(-1i, 0i), 0i); + let depth_top = load_depth(pixel_coordinates + vec2(0i, -1i), 0i); + let depth_bottom = load_depth(pixel_coordinates + vec2(0i, 1i), 0i); + let depth_right = load_depth(pixel_coordinates + vec2(1i, 0i), 0i); + + // Calculate the depth differences (large differences represent object edges) + var edge_info = vec4(depth_left, depth_right, depth_top, depth_bottom) - depth_center; + let slope_left_right = (edge_info.y - edge_info.x) * 0.5; + let slope_top_bottom = (edge_info.w - edge_info.z) * 0.5; + let edge_info_slope_adjusted = edge_info + vec4(slope_left_right, -slope_left_right, slope_top_bottom, -slope_top_bottom); + edge_info = min(abs(edge_info), abs(edge_info_slope_adjusted)); + let bias = 0.25; // Using the bias and then saturating nudges the values a bit + let scale = depth_center * 0.011; // Weight the edges by their distance from the camera + edge_info = saturate((1.0 + bias) - edge_info / scale); // Apply the bias and scale, and invert edge_info so that small values become large, and vice versa + + // Pack the edge info into the texture + let edge_info_packed = vec4(pack4x8unorm(edge_info), 0u, 0u, 0u); + textureStore(depth_differences, pixel_coordinates, edge_info_packed); + + return depth_center; +} + +fn reconstruct_view_space_position(depth: f32, uv: vec2) -> vec3 { + let clip_xy = vec2(uv.x * 2.0 - 1.0, 1.0 - 2.0 * uv.y); + let t = uniforms.inverse_projection * vec4(clip_xy, depth, 1.0); + let view_xyz = t.xyz / t.w; + return view_xyz; +} + +fn view_position_at(pixel_coordinates: vec2) -> vec3 { + let depth = load_depth(pixel_coordinates, 0i); + let uv = (vec2(pixel_coordinates) + 0.5) * uniforms.inv_size; + return reconstruct_view_space_position(depth, uv); +} + +// PORT: replaces Bevy's load_normal_view_space (which reads a prepass normal texture we do +// not have). Accurate view-space normal reconstruction from depth, atyuwen's 5-tap method: +// for each axis, extrapolate the center depth from the two taps on each side and derive the +// tangent from whichever side predicts it better. This keeps normals stable across depth +// discontinuities where naive derivatives smear. +fn reconstruct_normal(pixel_coordinates: vec2, pixel_position: vec3, depth_center: f32) -> vec3 { + let depth_left1 = load_depth(pixel_coordinates + vec2(-1i, 0i), 0i); + let depth_left2 = load_depth(pixel_coordinates + vec2(-2i, 0i), 0i); + let depth_right1 = load_depth(pixel_coordinates + vec2(1i, 0i), 0i); + let depth_right2 = load_depth(pixel_coordinates + vec2(2i, 0i), 0i); + let depth_top1 = load_depth(pixel_coordinates + vec2(0i, -1i), 0i); + let depth_top2 = load_depth(pixel_coordinates + vec2(0i, -2i), 0i); + let depth_bottom1 = load_depth(pixel_coordinates + vec2(0i, 1i), 0i); + let depth_bottom2 = load_depth(pixel_coordinates + vec2(0i, 2i), 0i); + + let use_left = abs(2.0 * depth_left1 - depth_left2 - depth_center) < + abs(2.0 * depth_right1 - depth_right2 - depth_center); + let use_top = abs(2.0 * depth_top1 - depth_top2 - depth_center) < + abs(2.0 * depth_bottom1 - depth_bottom2 - depth_center); + + var ddx: vec3; + if use_left { + ddx = pixel_position - view_position_at(pixel_coordinates + vec2(-1i, 0i)); + } else { + ddx = view_position_at(pixel_coordinates + vec2(1i, 0i)) - pixel_position; + } + var ddy: vec3; + if use_top { + ddy = pixel_position - view_position_at(pixel_coordinates + vec2(0i, -1i)); + } else { + ddy = view_position_at(pixel_coordinates + vec2(0i, 1i)) - pixel_position; + } + + var normal = normalize(cross(ddy, ddx)); + // Guard the orientation: the normal must face the camera. + if dot(normal, pixel_position) > 0.0 { + normal = -normal; + } + return normal; +} + +fn load_and_reconstruct_view_space_position(uv: vec2, sample_mip_level: f32) -> vec3 { + // PORT: point-sample the selected mip explicitly instead of textureSampleLevel. + let mip_level = i32(sample_mip_level + 0.5); + let mip_size = max(vec2(uniforms.size) >> vec2(u32(mip_level)), vec2(1i)); + let depth = load_depth(vec2(uv * vec2(mip_size)), mip_level); + return reconstruct_view_space_position(depth, uv); +} + +@compute +@workgroup_size(8, 8, 1) +fn gtao(@builtin(global_invocation_id) global_id: vec3) { + let slice_count = uniforms.slice_count; + let samples_per_slice_side = uniforms.samples_per_slice_side; + let effect_radius = uniforms.effect_radius; + let falloff_range = 0.615 * effect_radius; + let falloff_from = effect_radius * (1.0 - 0.615); + let falloff_mul = -1.0 / falloff_range; + let falloff_add = falloff_from / falloff_range + 1.0; + + let pixel_coordinates = vec2(global_id.xy); + let uv = (vec2(pixel_coordinates) + 0.5) * uniforms.inv_size; + + var pixel_depth = calculate_neighboring_depth_differences(pixel_coordinates); + let raw_depth = pixel_depth; + pixel_depth += 0.00001; // Avoid depth precision issues + + let pixel_position = reconstruct_view_space_position(pixel_depth, uv); + // PORT: the reconstruction differences the center position against neighbor positions + // built from unbiased depths, so its center must use the raw depth too: at this game's + // depth scale (far plane 200000 -> depth ~5e-3) Bevy's +0.00001 bias is comparable to a + // one-pixel depth step, and a biased center corrupts both tangents. + let pixel_normal = reconstruct_normal( + pixel_coordinates, reconstruct_view_space_position(raw_depth, uv), raw_depth); + let view_vec = normalize(-pixel_position); + + let noise = load_noise(pixel_coordinates); + let sample_scale = (-0.5 * effect_radius * uniforms.projection[0][0]) / pixel_position.z; + + var visibility = 0.0; + for (var slice_t = 0.0; slice_t < slice_count; slice_t += 1.0) { + let slice = slice_t + noise.x; + let phi = (PI / slice_count) * slice; + let omega = vec2(cos(phi), sin(phi)); + + let direction = vec3(omega.xy, 0.0); + let orthographic_direction = direction - (dot(direction, view_vec) * view_vec); + let axis = cross(direction, view_vec); + let projected_normal = pixel_normal - axis * dot(pixel_normal, axis); + let projected_normal_length = length(projected_normal); + + let sign_norm = sign(dot(orthographic_direction, projected_normal)); + let cos_norm = saturate(dot(projected_normal, view_vec) / projected_normal_length); + let n = sign_norm * fast_acos(cos_norm); + + let min_cos_horizon_1 = cos(n + HALF_PI); + let min_cos_horizon_2 = cos(n - HALF_PI); + var cos_horizon_1 = min_cos_horizon_1; + var cos_horizon_2 = min_cos_horizon_2; + let sample_mul = vec2(omega.x, -omega.y) * sample_scale; + for (var sample_t = 0.0; sample_t < samples_per_slice_side; sample_t += 1.0) { + var sample_noise = (slice_t + sample_t * samples_per_slice_side) * 0.6180339887498948482; + sample_noise = fract(noise.y + sample_noise); + + var s = (sample_t + sample_noise) / samples_per_slice_side; + s *= s; // https://github.com/GameTechDev/XeGTAO#sample-distribution + let sample = s * sample_mul; + + // * uniforms.size gets us from [0, 1] to [0, viewport_size], which is needed for this to get the correct mip levels + let sample_mip_level = clamp(log2(length(sample * uniforms.size)) - 3.3, 0.0, 4.0); // https://github.com/GameTechDev/XeGTAO#memory-bandwidth-bottleneck + let sample_position_1 = load_and_reconstruct_view_space_position(uv + sample, sample_mip_level); + let sample_position_2 = load_and_reconstruct_view_space_position(uv - sample, sample_mip_level); + + let sample_difference_1 = sample_position_1 - pixel_position; + let sample_difference_2 = sample_position_2 - pixel_position; + let sample_distance_1 = length(sample_difference_1); + let sample_distance_2 = length(sample_difference_2); + var sample_cos_horizon_1 = dot(sample_difference_1 / sample_distance_1, view_vec); + var sample_cos_horizon_2 = dot(sample_difference_2 / sample_distance_2, view_vec); + + let weight_1 = saturate(sample_distance_1 * falloff_mul + falloff_add); + let weight_2 = saturate(sample_distance_2 * falloff_mul + falloff_add); + sample_cos_horizon_1 = mix(min_cos_horizon_1, sample_cos_horizon_1, weight_1); + sample_cos_horizon_2 = mix(min_cos_horizon_2, sample_cos_horizon_2, weight_2); + + cos_horizon_1 = max(cos_horizon_1, sample_cos_horizon_1); + cos_horizon_2 = max(cos_horizon_2, sample_cos_horizon_2); + } + + let horizon_1 = fast_acos(cos_horizon_1); + let horizon_2 = -fast_acos(cos_horizon_2); + let v1 = (cos_norm + 2.0 * horizon_1 * sin(n) - cos(2.0 * horizon_1 - n)) / 4.0; + let v2 = (cos_norm + 2.0 * horizon_2 * sin(n) - cos(2.0 * horizon_2 - n)) / 4.0; + visibility += projected_normal_length * (v1 + v2); + } + visibility /= slice_count; + visibility = clamp(visibility, 0.03, 1.0); + + textureStore(ambient_occlusion, pixel_coordinates, vec4(visibility, 0.0, 0.0, 0.0)); +} diff --git a/mods/ao_mod/res/licenses/BEVY-APACHE-2.0.txt b/mods/ao_mod/res/licenses/BEVY-APACHE-2.0.txt new file mode 100644 index 0000000000..d9a10c0d8e --- /dev/null +++ b/mods/ao_mod/res/licenses/BEVY-APACHE-2.0.txt @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/mods/ao_mod/res/licenses/BEVY-MIT.txt b/mods/ao_mod/res/licenses/BEVY-MIT.txt new file mode 100644 index 0000000000..9cf106272a --- /dev/null +++ b/mods/ao_mod/res/licenses/BEVY-MIT.txt @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mods/ao_mod/res/licenses/XEGTAO-MIT.txt b/mods/ao_mod/res/licenses/XEGTAO-MIT.txt new file mode 100644 index 0000000000..2b1bd1561b --- /dev/null +++ b/mods/ao_mod/res/licenses/XEGTAO-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (C) 2016-2021, Intel Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/mods/ao_mod/res/preprocess_depth.wgsl b/mods/ao_mod/res/preprocess_depth.wgsl new file mode 100644 index 0000000000..98fe1d5400 --- /dev/null +++ b/mods/ao_mod/res/preprocess_depth.wgsl @@ -0,0 +1,138 @@ +// Inputs a depth texture and outputs a MIP-chain of depths. +// +// Because SSAO's performance is bound by texture reads, this increases +// performance over using the full resolution depth for every sample. +// +// Reference: https://research.nvidia.com/sites/default/files/pubs/2012-06_Scalable-Ambient-Obscurance/McGuire12SAO.pdf, section 2.2 +// +// Ported from Bevy Engine, crates/bevy_pbr/src/ssao/preprocess_depth.wgsl (v0.13.2), +// licensed MIT OR Apache-2.0 (see res/licenses/), itself derived from Intel XeGTAO (MIT). +// +// PORT: sampler-based gathers replaced with textureLoad (r32float is not filterable without +// optional device features), Bevy view uniforms replaced with the mod's own uniform block, +// storage format r16float -> r32float (core WebGPU storage format). MIP 4 moved into its own +// entry point (core WebGPU limit is 4 storage textures per stage). + +struct Uniforms { + projection: mat4x4f, + inverse_projection: mat4x4f, + size: vec2f, // AO chain size in pixels (MIP 0 of the preprocessed depth) + inv_size: vec2f, + depth_scale: vec2f, // input depth snapshot pixels per chain pixel (1 or 2) + effect_radius: f32, // view-space units + intensity: f32, + slice_count: f32, + samples_per_slice_side: f32, + debug_view: u32, + _pad: f32, +} + +@group(0) @binding(0) var input_depth: texture_2d; +@group(0) @binding(1) var preprocessed_depth_mip0: texture_storage_2d; +@group(0) @binding(2) var preprocessed_depth_mip1: texture_storage_2d; +@group(0) @binding(3) var preprocessed_depth_mip2: texture_storage_2d; +@group(0) @binding(4) var preprocessed_depth_mip3: texture_storage_2d; +@group(0) @binding(5) var uniforms: Uniforms; +// downsample_mip4 entry point only (disjoint subresources of the same texture). +@group(0) @binding(6) var preprocessed_depth_mip3_in: texture_2d; +@group(0) @binding(7) var preprocessed_depth_mip4: texture_storage_2d; + +// PORT: replaces the textureGather of the input depth with explicit loads (also handles the +// half-resolution case, where one chain texel covers depth_scale snapshot texels). +fn load_input_depth(pixel_coordinates: vec2) -> f32 { + let input_size = vec2(uniforms.size * uniforms.depth_scale); + let coordinates = clamp(vec2(vec2(pixel_coordinates) * uniforms.depth_scale), + vec2(0i), input_size - 1i); + return textureLoad(input_depth, coordinates, 0i).r; +} + +// Using 4 depths from the previous MIP, compute a weighted average for the depth of the current MIP +fn weighted_average(depth0: f32, depth1: f32, depth2: f32, depth3: f32) -> f32 { + let depth_range_scale_factor = 0.75; + let effect_radius = depth_range_scale_factor * 0.5 * 1.457; + let falloff_range = 0.615 * effect_radius; + let falloff_from = effect_radius * (1.0 - 0.615); + let falloff_mul = -1.0 / falloff_range; + let falloff_add = falloff_from / falloff_range + 1.0; + + let min_depth = min(min(depth0, depth1), min(depth2, depth3)); + let weight0 = saturate((depth0 - min_depth) * falloff_mul + falloff_add); + let weight1 = saturate((depth1 - min_depth) * falloff_mul + falloff_add); + let weight2 = saturate((depth2 - min_depth) * falloff_mul + falloff_add); + let weight3 = saturate((depth3 - min_depth) * falloff_mul + falloff_add); + let weight_total = weight0 + weight1 + weight2 + weight3; + + return ((weight0 * depth0) + (weight1 * depth1) + (weight2 * depth2) + (weight3 * depth3)) / weight_total; +} + +// Used to share the depths from the previous MIP level between all invocations in a workgroup +var previous_mip_depth: array, 8>; + +@compute +@workgroup_size(8, 8, 1) +fn preprocess_depth(@builtin(global_invocation_id) global_id: vec3, @builtin(local_invocation_id) local_id: vec3) { + let base_coordinates = vec2(global_id.xy); + + // MIP 0 - Copy 4 texels from the input depth (per invocation, 8x8 invocations per workgroup) + let pixel_coordinates0 = base_coordinates * 2i; + let pixel_coordinates1 = pixel_coordinates0 + vec2(1i, 0i); + let pixel_coordinates2 = pixel_coordinates0 + vec2(0i, 1i); + let pixel_coordinates3 = pixel_coordinates0 + vec2(1i, 1i); + let depth0 = load_input_depth(pixel_coordinates0); + let depth1 = load_input_depth(pixel_coordinates1); + let depth2 = load_input_depth(pixel_coordinates2); + let depth3 = load_input_depth(pixel_coordinates3); + textureStore(preprocessed_depth_mip0, pixel_coordinates0, vec4(depth0, 0.0, 0.0, 0.0)); + textureStore(preprocessed_depth_mip0, pixel_coordinates1, vec4(depth1, 0.0, 0.0, 0.0)); + textureStore(preprocessed_depth_mip0, pixel_coordinates2, vec4(depth2, 0.0, 0.0, 0.0)); + textureStore(preprocessed_depth_mip0, pixel_coordinates3, vec4(depth3, 0.0, 0.0, 0.0)); + + // MIP 1 - Weighted average of MIP 0's depth values (per invocation, 8x8 invocations per workgroup) + let depth_mip1 = weighted_average(depth0, depth1, depth2, depth3); + textureStore(preprocessed_depth_mip1, base_coordinates, vec4(depth_mip1, 0.0, 0.0, 0.0)); + previous_mip_depth[local_id.x][local_id.y] = depth_mip1; + + workgroupBarrier(); + + // MIP 2 - Weighted average of MIP 1's depth values (per invocation, 4x4 invocations per workgroup) + if all(local_id.xy % vec2(2u) == vec2(0u)) { + let mip2_depth0 = previous_mip_depth[local_id.x + 0u][local_id.y + 0u]; + let mip2_depth1 = previous_mip_depth[local_id.x + 1u][local_id.y + 0u]; + let mip2_depth2 = previous_mip_depth[local_id.x + 0u][local_id.y + 1u]; + let mip2_depth3 = previous_mip_depth[local_id.x + 1u][local_id.y + 1u]; + let depth_mip2 = weighted_average(mip2_depth0, mip2_depth1, mip2_depth2, mip2_depth3); + textureStore(preprocessed_depth_mip2, base_coordinates / 2i, vec4(depth_mip2, 0.0, 0.0, 0.0)); + previous_mip_depth[local_id.x][local_id.y] = depth_mip2; + } + + workgroupBarrier(); + + // MIP 3 - Weighted average of MIP 2's depth values (per invocation, 2x2 invocations per workgroup) + if all(local_id.xy % vec2(4u) == vec2(0u)) { + let mip3_depth0 = previous_mip_depth[local_id.x + 0u][local_id.y + 0u]; + let mip3_depth1 = previous_mip_depth[local_id.x + 2u][local_id.y + 0u]; + let mip3_depth2 = previous_mip_depth[local_id.x + 0u][local_id.y + 2u]; + let mip3_depth3 = previous_mip_depth[local_id.x + 2u][local_id.y + 2u]; + let depth_mip3 = weighted_average(mip3_depth0, mip3_depth1, mip3_depth2, mip3_depth3); + textureStore(preprocessed_depth_mip3, base_coordinates / 4i, vec4(depth_mip3, 0.0, 0.0, 0.0)); + previous_mip_depth[local_id.x][local_id.y] = depth_mip3; + } +} + +// MIP 4: weighted average of MIP 3's depth values, as a second (tiny) dispatch. +@compute +@workgroup_size(8, 8, 1) +fn downsample_mip4(@builtin(global_invocation_id) global_id: vec3) { + let base_coordinates = vec2(global_id.xy); + let mip3_size = max(vec2(textureDimensions(preprocessed_depth_mip3_in)), vec2(1i)); + let coordinates0 = clamp(base_coordinates * 2i, vec2(0i), mip3_size - 1i); + let coordinates1 = clamp(base_coordinates * 2i + vec2(1i, 0i), vec2(0i), mip3_size - 1i); + let coordinates2 = clamp(base_coordinates * 2i + vec2(0i, 1i), vec2(0i), mip3_size - 1i); + let coordinates3 = clamp(base_coordinates * 2i + vec2(1i, 1i), vec2(0i), mip3_size - 1i); + let depth0 = textureLoad(preprocessed_depth_mip3_in, coordinates0, 0i).r; + let depth1 = textureLoad(preprocessed_depth_mip3_in, coordinates1, 0i).r; + let depth2 = textureLoad(preprocessed_depth_mip3_in, coordinates2, 0i).r; + let depth3 = textureLoad(preprocessed_depth_mip3_in, coordinates3, 0i).r; + let depth_mip4 = weighted_average(depth0, depth1, depth2, depth3); + textureStore(preprocessed_depth_mip4, base_coordinates, vec4(depth_mip4, 0.0, 0.0, 0.0)); +} diff --git a/mods/ao_mod/src/mod.cpp b/mods/ao_mod/src/mod.cpp new file mode 100644 index 0000000000..a7eb4f4028 --- /dev/null +++ b/mods/ao_mod/src/mod.cpp @@ -0,0 +1,931 @@ +// Ambient occlusion (GTAO) example mod. +// +// Showcases the gfx service's compute tasks and the camera service: after opaque scene draws, +// before translucent/fog overlays, the scene depth is resolved and a three-dispatch compute +// chain (depth MIP prefilter, GTAO, spatial denoise) produces a visibility texture that a +// fullscreen draw multiplies over the world. +// +// The WGSL in res/ is ported from Bevy Engine's SSAO implementation (MIT OR Apache-2.0), +// itself based on Intel XeGTAO (MIT); see res/licenses/ and the `PORT:` notes in the shaders. + +#include "mods/service.hpp" +#include "mods/svc/camera.h" +#include "mods/svc/config.h" +#include "mods/svc/gfx.h" +#include "mods/svc/log.h" +#include "mods/svc/resource.h" +#include "mods/svc/ui.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +DEFINE_MOD(); +IMPORT_SERVICE(LogService, svc_log); +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); + +namespace { + +ConfigVarHandle g_cvarEnabled = 0; +ConfigVarHandle g_cvarQuality = 0; +ConfigVarHandle g_cvarRadius = 0; +ConfigVarHandle g_cvarIntensity = 0; +ConfigVarHandle g_cvarHalfRes = 0; +ConfigVarHandle g_cvarDebugView = 0; + +GfxComputeTypeHandle g_computeType = 0; +GfxDrawTypeHandle g_drawType = 0; +GfxStageHookHandle g_afterOpaqueHook = 0; +UiWindowHandle g_controlsWindow = 0; + +ResourceBuffer g_preprocessSource = RESOURCE_BUFFER_INIT; +ResourceBuffer g_gtaoSource = RESOURCE_BUFFER_INIT; +ResourceBuffer g_denoiseSource = RESOURCE_BUFFER_INIT; +ResourceBuffer g_compositeSource = RESOURCE_BUFFER_INIT; + +GfxDeviceInfo g_deviceInfo = GFX_DEVICE_INFO_INIT; +WGPUComputePipeline g_preprocessPipeline = nullptr; +WGPUComputePipeline g_mip4Pipeline = nullptr; +WGPUComputePipeline g_gtaoPipeline = nullptr; +WGPUComputePipeline g_denoisePipeline = nullptr; +WGPUBindGroupLayout g_preprocessLayout = nullptr; +WGPUBindGroupLayout g_mip4Layout = nullptr; +WGPUBindGroupLayout g_gtaoLayout = nullptr; +WGPUBindGroupLayout g_denoiseLayout = nullptr; +WGPURenderPipeline g_compositePipeline = nullptr; +WGPURenderPipeline g_compositeDebugPipeline = nullptr; +WGPUBindGroupLayout g_compositeLayout = nullptr; +WGPUBindGroupLayout g_compositeDebugLayout = nullptr; +WGPUTexture g_hilbertLut = nullptr; +WGPUTextureView g_hilbertLutView = nullptr; + +// AO chain targets, recreated when the render size (or halfRes) changes. Old sets are retired +// for a few frames instead of released immediately: payloads embedding their views may still +// be in flight on the render worker. +struct AoTargets { + uint32_t width = 0; + uint32_t height = 0; + WGPUTexture preprocessedDepth = nullptr; + WGPUTextureView preprocessedDepthMips[5] = {}; + WGPUTextureView preprocessedDepthAll = nullptr; + WGPUTexture aoNoisy = nullptr; + WGPUTextureView aoNoisyView = nullptr; + WGPUTexture depthDifferences = nullptr; + WGPUTextureView depthDifferencesView = nullptr; + WGPUTexture aoFinal = nullptr; + WGPUTextureView aoFinalView = nullptr; +}; +AoTargets g_targets; +struct RetiredTargets { + AoTargets targets; + int framesLeft = 0; +}; +std::vector g_retiredTargets; + +bool g_warnedNoDepth = false; +bool g_loggedChain = false; +std::atomic g_chainExecuted{false}; + +// Mirror of the WGSL Uniforms struct (keep in sync with res/*.wgsl). +struct AoUniforms { + float projection[16]; + float inverse_projection[16]; + float size[2]; + float inv_size[2]; + float depth_scale[2]; + float effect_radius; + float intensity; + float slice_count; + float samples_per_slice_side; + uint32_t debug_view; + float _pad; +}; +static_assert(sizeof(AoUniforms) % 16 == 0); + +struct ComputePayload { + WGPUTextureView depth; // frame-pooled scene depth snapshot + WGPUTextureView preprocessedDepthMips[5]; + WGPUTextureView preprocessedDepthAll; + WGPUTextureView aoNoisy; + WGPUTextureView depthDifferences; + WGPUTextureView aoFinal; + uint32_t uniform_offset; + uint32_t uniform_size; + uint32_t width; + uint32_t height; +}; +static_assert(sizeof(ComputePayload) <= GFX_INLINE_DRAW_PAYLOAD_SIZE); +static_assert(std::is_trivially_copyable_v); + +struct CompositePayload { + WGPUTextureView aoFinal; + WGPUTextureView preprocessedDepth; // debug views reconstruct normals/depth from it + WGPUTextureView sceneDepth; // raw snapshot, for the bypass debug views + uint32_t uniform_offset; + uint32_t uniform_size; + uint32_t debug_view; +}; +static_assert(sizeof(CompositePayload) <= GFX_INLINE_DRAW_PAYLOAD_SIZE); +static_assert(std::is_trivially_copyable_v); + +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; +} + +// XeGTAO/Bevy quality presets: slices x (samples per slice side * 2). +void quality_counts(int64_t quality, float& sliceCount, float& samplesPerSliceSide) { + switch (std::clamp(quality, 0, 3)) { + case 0: + sliceCount = 1.0f; + samplesPerSliceSide = 2.0f; + break; + case 1: + sliceCount = 2.0f; + samplesPerSliceSide = 2.0f; + break; + default: + case 2: + sliceCount = 3.0f; + samplesPerSliceSide = 3.0f; + break; + case 3: + sliceCount = 9.0f; + samplesPerSliceSide = 3.0f; + break; + } +} + +WGPUShaderModule create_shader_module(const char* label, const ResourceBuffer& source) { + WGPUShaderSourceWGSL wgsl = WGPU_SHADER_SOURCE_WGSL_INIT; + wgsl.code = {static_cast(source.data), source.size}; + WGPUShaderModuleDescriptor moduleDesc = WGPU_SHADER_MODULE_DESCRIPTOR_INIT; + moduleDesc.nextInChain = &wgsl.chain; + moduleDesc.label = {label, WGPU_STRLEN}; + return wgpuDeviceCreateShaderModule(g_deviceInfo.device, &moduleDesc); +} + +bool build_compute_pipeline(const char* label, const ResourceBuffer& source, const char* entry, + WGPUComputePipeline& outPipeline, WGPUBindGroupLayout& outLayout) { + WGPUShaderModule module = create_shader_module(label, source); + if (module == nullptr) { + return false; + } + WGPUComputePipelineDescriptor pipelineDesc = WGPU_COMPUTE_PIPELINE_DESCRIPTOR_INIT; + pipelineDesc.label = {label, WGPU_STRLEN}; + pipelineDesc.compute.module = module; + pipelineDesc.compute.entryPoint = {entry, WGPU_STRLEN}; + outPipeline = wgpuDeviceCreateComputePipeline(g_deviceInfo.device, &pipelineDesc); + wgpuShaderModuleRelease(module); + if (outPipeline == nullptr) { + return false; + } + outLayout = wgpuComputePipelineGetBindGroupLayout(outPipeline, 0); + return outLayout != nullptr; +} + +bool build_composite_pipeline( + bool blend, WGPURenderPipeline& outPipeline, WGPUBindGroupLayout& outLayout) { + WGPUShaderModule module = create_shader_module("AO composite", g_compositeSource); + if (module == nullptr) { + return false; + } + + // Multiply blend + 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; + // Depth state must match the EFB pass despite never touching depth. + 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 ? "AO composite" : "AO 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; +} + +// Hilbert curve index LUT for the R2 noise sequence, generated once at init. +// Ported from Bevy's generate_hilbert_index_lut (https://www.shadertoy.com/view/3tB3z3). +uint16_t hilbert_index(uint16_t x, uint16_t y) { + uint16_t index = 0; + for (uint16_t level = 32; level > 0; level /= 2) { + const uint16_t regionX = (x & level) > 0 ? 1 : 0; + const uint16_t regionY = (y & level) > 0 ? 1 : 0; + index += level * level * ((3 * regionX) ^ regionY); + if (regionY == 0) { + if (regionX == 1) { + x = 63 - x; + y = 63 - y; + } + std::swap(x, y); + } + } + return index; +} + +bool build_hilbert_lut() { + WGPUTextureDescriptor texDesc = WGPU_TEXTURE_DESCRIPTOR_INIT; + texDesc.label = {"AO hilbert LUT", WGPU_STRLEN}; + texDesc.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_CopyDst; + texDesc.size = {64, 64, 1}; + texDesc.format = WGPUTextureFormat_R16Uint; + g_hilbertLut = wgpuDeviceCreateTexture(g_deviceInfo.device, &texDesc); + if (g_hilbertLut == nullptr) { + return false; + } + g_hilbertLutView = wgpuTextureCreateView(g_hilbertLut, nullptr); + if (g_hilbertLutView == nullptr) { + return false; + } + + uint16_t lut[64 * 64]; + for (uint16_t y = 0; y < 64; ++y) { + for (uint16_t x = 0; x < 64; ++x) { + lut[y * 64 + x] = hilbert_index(x, y); + } + } + WGPUTexelCopyTextureInfo dst = WGPU_TEXEL_COPY_TEXTURE_INFO_INIT; + dst.texture = g_hilbertLut; + WGPUTexelCopyBufferLayout layout{.offset = 0, .bytesPerRow = 64 * 2, .rowsPerImage = 64}; + WGPUExtent3D extent{64, 64, 1}; + wgpuQueueWriteTexture(g_deviceInfo.queue, &dst, lut, sizeof(lut), &layout, &extent); + return true; +} + +void release_targets(AoTargets& targets) { + for (auto*& view : targets.preprocessedDepthMips) { + if (view != nullptr) { + wgpuTextureViewRelease(view); + view = nullptr; + } + } + const auto releaseView = [](WGPUTextureView& view) { + if (view != nullptr) { + wgpuTextureViewRelease(view); + view = nullptr; + } + }; + const auto releaseTexture = [](WGPUTexture& texture) { + if (texture != nullptr) { + wgpuTextureRelease(texture); + texture = nullptr; + } + }; + releaseView(targets.preprocessedDepthAll); + releaseView(targets.aoNoisyView); + releaseView(targets.depthDifferencesView); + releaseView(targets.aoFinalView); + releaseTexture(targets.preprocessedDepth); + releaseTexture(targets.aoNoisy); + releaseTexture(targets.depthDifferences); + releaseTexture(targets.aoFinal); + targets.width = targets.height = 0; +} + +void tick_retired_targets() { + for (auto it = g_retiredTargets.begin(); it != g_retiredTargets.end();) { + if (--it->framesLeft <= 0) { + release_targets(it->targets); + it = g_retiredTargets.erase(it); + } else { + ++it; + } + } +} + +bool ensure_targets(uint32_t width, uint32_t height) { + if (g_targets.width == width && g_targets.height == height) { + return true; + } + if (g_targets.width != 0) { + g_retiredTargets.push_back(RetiredTargets{std::exchange(g_targets, AoTargets{}), 4}); + } + + const auto createStorageTexture = [&](const char* label, WGPUTextureFormat format, + uint32_t mipCount, WGPUTexture& outTexture) { + WGPUTextureDescriptor texDesc = WGPU_TEXTURE_DESCRIPTOR_INIT; + texDesc.label = {label, WGPU_STRLEN}; + texDesc.usage = WGPUTextureUsage_StorageBinding | WGPUTextureUsage_TextureBinding; + texDesc.size = {width, height, 1}; + texDesc.format = format; + texDesc.mipLevelCount = mipCount; + outTexture = wgpuDeviceCreateTexture(g_deviceInfo.device, &texDesc); + return outTexture != nullptr; + }; + + bool ok = createStorageTexture("AO preprocessed depth", WGPUTextureFormat_R32Float, 5, + g_targets.preprocessedDepth) && + createStorageTexture("AO noisy", WGPUTextureFormat_R32Float, 1, g_targets.aoNoisy) && + createStorageTexture("AO depth differences", WGPUTextureFormat_R32Uint, 1, + g_targets.depthDifferences) && + createStorageTexture("AO final", WGPUTextureFormat_R32Float, 1, g_targets.aoFinal); + if (ok) { + for (uint32_t mip = 0; mip < 5 && ok; ++mip) { + WGPUTextureViewDescriptor viewDesc = WGPU_TEXTURE_VIEW_DESCRIPTOR_INIT; + viewDesc.baseMipLevel = mip; + viewDesc.mipLevelCount = 1; + g_targets.preprocessedDepthMips[mip] = + wgpuTextureCreateView(g_targets.preprocessedDepth, &viewDesc); + ok = g_targets.preprocessedDepthMips[mip] != nullptr; + } + } + if (ok) { + g_targets.preprocessedDepthAll = + wgpuTextureCreateView(g_targets.preprocessedDepth, nullptr); + g_targets.aoNoisyView = wgpuTextureCreateView(g_targets.aoNoisy, nullptr); + g_targets.depthDifferencesView = wgpuTextureCreateView(g_targets.depthDifferences, nullptr); + g_targets.aoFinalView = wgpuTextureCreateView(g_targets.aoFinal, nullptr); + ok = g_targets.preprocessedDepthAll != nullptr && g_targets.aoNoisyView != nullptr && + g_targets.depthDifferencesView != nullptr && g_targets.aoFinalView != nullptr; + } + if (!ok) { + release_targets(g_targets); + return false; + } + g_targets.width = width; + g_targets.height = height; + return true; +} + +constexpr uint32_t div_ceil(uint32_t numerator, uint32_t denominator) { + return (numerator + denominator - 1) / denominator; +} + +// Render worker thread: the AO chain as one compute pass with three dispatches. +void on_compute( + ModContext*, const GfxComputeContext* ctx, const void* payload, size_t payloadSize, void*) { + if (payloadSize != sizeof(ComputePayload)) { + return; + } + ComputePayload data; + std::memcpy(&data, payload, sizeof(data)); + if (data.depth == nullptr || g_preprocessPipeline == nullptr) { + return; + } + + const auto makeBindGroup = [&](WGPUBindGroupLayout layout, + std::initializer_list entries) { + WGPUBindGroupDescriptor bindGroupDesc = WGPU_BIND_GROUP_DESCRIPTOR_INIT; + bindGroupDesc.layout = layout; + bindGroupDesc.entryCount = entries.size(); + bindGroupDesc.entries = entries.begin(); + return wgpuDeviceCreateBindGroup(ctx->device, &bindGroupDesc); + }; + const auto textureEntry = [](uint32_t binding, WGPUTextureView view) { + WGPUBindGroupEntry entry = WGPU_BIND_GROUP_ENTRY_INIT; + entry.binding = binding; + entry.textureView = view; + return entry; + }; + const auto uniformEntry = [&](uint32_t binding) { + WGPUBindGroupEntry entry = WGPU_BIND_GROUP_ENTRY_INIT; + entry.binding = binding; + entry.buffer = ctx->uniform_buffer; + entry.offset = data.uniform_offset; + entry.size = data.uniform_size; + return entry; + }; + + WGPUBindGroup preprocessGroup = makeBindGroup(g_preprocessLayout, + {textureEntry(0, data.depth), textureEntry(1, data.preprocessedDepthMips[0]), + textureEntry(2, data.preprocessedDepthMips[1]), + textureEntry(3, data.preprocessedDepthMips[2]), + textureEntry(4, data.preprocessedDepthMips[3]), uniformEntry(5)}); + WGPUBindGroup mip4Group = + makeBindGroup(g_mip4Layout, {textureEntry(6, data.preprocessedDepthMips[3]), + textureEntry(7, data.preprocessedDepthMips[4])}); + WGPUBindGroup gtaoGroup = makeBindGroup( + g_gtaoLayout, {textureEntry(0, data.preprocessedDepthAll), + textureEntry(1, g_hilbertLutView), textureEntry(2, data.aoNoisy), + textureEntry(3, data.depthDifferences), uniformEntry(4)}); + WGPUBindGroup denoiseGroup = makeBindGroup( + g_denoiseLayout, {textureEntry(0, data.aoNoisy), textureEntry(1, data.depthDifferences), + textureEntry(2, data.aoFinal), uniformEntry(3)}); + if (preprocessGroup == nullptr || mip4Group == nullptr || gtaoGroup == nullptr || + denoiseGroup == nullptr) + { + const auto release = [](WGPUBindGroup group) { + if (group != nullptr) { + wgpuBindGroupRelease(group); + } + }; + release(preprocessGroup); + release(mip4Group); + release(gtaoGroup); + release(denoiseGroup); + return; + } + + WGPUComputePassDescriptor passDesc = WGPU_COMPUTE_PASS_DESCRIPTOR_INIT; + passDesc.label = {"AO chain", WGPU_STRLEN}; + WGPUComputePassEncoder pass = wgpuCommandEncoderBeginComputePass(ctx->encoder, &passDesc); + // Each preprocess workgroup covers 16x16 MIP-0 texels (8x8 invocations, 2x2 texels each). + wgpuComputePassEncoderSetPipeline(pass, g_preprocessPipeline); + wgpuComputePassEncoderSetBindGroup(pass, 0, preprocessGroup, 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups( + pass, div_ceil(data.width, 16), div_ceil(data.height, 16), 1); + wgpuComputePassEncoderSetPipeline(pass, g_mip4Pipeline); + wgpuComputePassEncoderSetBindGroup(pass, 0, mip4Group, 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups(pass, div_ceil(std::max(data.width >> 4, 1u), 8), + div_ceil(std::max(data.height >> 4, 1u), 8), 1); + wgpuComputePassEncoderSetPipeline(pass, g_gtaoPipeline); + wgpuComputePassEncoderSetBindGroup(pass, 0, gtaoGroup, 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups( + pass, div_ceil(data.width, 8), div_ceil(data.height, 8), 1); + wgpuComputePassEncoderSetPipeline(pass, g_denoisePipeline); + wgpuComputePassEncoderSetBindGroup(pass, 0, denoiseGroup, 0, nullptr); + wgpuComputePassEncoderDispatchWorkgroups( + pass, div_ceil(data.width, 8), div_ceil(data.height, 8), 1); + wgpuComputePassEncoderEnd(pass); + wgpuComputePassEncoderRelease(pass); + + wgpuBindGroupRelease(preprocessGroup); + wgpuBindGroupRelease(mip4Group); + wgpuBindGroupRelease(gtaoGroup); + wgpuBindGroupRelease(denoiseGroup); + g_chainExecuted.store(true, std::memory_order_release); +} + +// Render worker thread: composite the AO over the scene (or show it, in debug view). +void on_draw( + ModContext*, const GfxDrawContext* ctx, const void* payload, size_t payloadSize, void*) { + if (payloadSize != sizeof(CompositePayload)) { + return; + } + CompositePayload data; + std::memcpy(&data, payload, sizeof(data)); + WGPURenderPipeline pipeline = + data.debug_view != 0 ? g_compositeDebugPipeline : g_compositePipeline; + WGPUBindGroupLayout layout = data.debug_view != 0 ? g_compositeDebugLayout : g_compositeLayout; + if (data.aoFinal == nullptr || data.preprocessedDepth == nullptr || + data.sceneDepth == 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.aoFinal; + entries[1].binding = 1; + entries[1].textureView = data.preprocessedDepth; + entries[2].binding = 2; + entries[2].textureView = data.sceneDepth; + entries[3].binding = 3; + entries[3].buffer = ctx->uniform_buffer; + entries[3].offset = data.uniform_offset; + entries[3].size = data.uniform_size; + 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); +} + +// Game thread, after opaque scene draws and before translucent/fog overlay lists. +void on_scene_after_opaque(ModContext*, const GfxStageContext* stageCtx, void*) { + tick_retired_targets(); + if (!get_bool_option(g_cvarEnabled, true)) { + return; + } + if (stageCtx == nullptr || stageCtx->struct_size < sizeof(GfxStageContext) || + stageCtx->game_view == nullptr) + { + return; + } + + CameraInfo camera = CAMERA_INFO_INIT; + if (svc_camera->get_camera(mod_ctx, stageCtx->game_view, &camera) != MOD_OK) { + return; + } + + 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) + { + if (!g_warnedNoDepth) { + g_warnedNoDepth = true; + svc_log->warn(mod_ctx, "depth snapshots unavailable; AO disabled"); + } + return; + } + + const bool halfRes = get_bool_option(g_cvarHalfRes, false); + const uint32_t divisor = halfRes ? 2 : 1; + const uint32_t width = resolved.width / divisor; + const uint32_t height = resolved.height / divisor; + if (width < 32 || height < 32 || !ensure_targets(width, height)) { + return; + } + + AoUniforms uniforms{}; + std::memcpy(uniforms.projection, camera.proj_from_view, sizeof(uniforms.projection)); + std::memcpy( + uniforms.inverse_projection, camera.view_from_proj, sizeof(uniforms.inverse_projection)); + uniforms.size[0] = static_cast(width); + uniforms.size[1] = static_cast(height); + uniforms.inv_size[0] = 1.0f / uniforms.size[0]; + uniforms.inv_size[1] = 1.0f / uniforms.size[1]; + uniforms.depth_scale[0] = static_cast(resolved.width) / uniforms.size[0]; + uniforms.depth_scale[1] = static_cast(resolved.height) / uniforms.size[1]; + uniforms.effect_radius = + static_cast(std::clamp(get_int_option(g_cvarRadius, 70), 10, 500)); + uniforms.intensity = + static_cast(std::clamp(get_int_option(g_cvarIntensity, 100), 0, 100)) / + 100.0f; + quality_counts( + get_int_option(g_cvarQuality, 2), uniforms.slice_count, uniforms.samples_per_slice_side); + const uint32_t debugMode = + static_cast(std::clamp(get_int_option(g_cvarDebugView, 0), 0, 4)); + uniforms.debug_view = debugMode; + + GfxRange uniformRange{0, 0}; + if (svc_gfx->push_uniform(mod_ctx, &uniforms, sizeof(uniforms), &uniformRange) != MOD_OK) { + return; + } + + ComputePayload computePayload{}; + computePayload.depth = resolved.depth; + for (int mip = 0; mip < 5; ++mip) { + computePayload.preprocessedDepthMips[mip] = g_targets.preprocessedDepthMips[mip]; + } + computePayload.preprocessedDepthAll = g_targets.preprocessedDepthAll; + computePayload.aoNoisy = g_targets.aoNoisyView; + computePayload.depthDifferences = g_targets.depthDifferencesView; + computePayload.aoFinal = g_targets.aoFinalView; + computePayload.uniform_offset = uniformRange.offset; + computePayload.uniform_size = uniformRange.size; + computePayload.width = width; + computePayload.height = height; + if (svc_gfx->push_compute(mod_ctx, g_computeType, &computePayload, sizeof(computePayload)) != + MOD_OK) + { + return; + } + + const CompositePayload drawPayload{g_targets.aoFinalView, g_targets.preprocessedDepthAll, + resolved.depth, uniformRange.offset, uniformRange.size, debugMode}; + svc_gfx->push_draw(mod_ctx, g_drawType, &drawPayload, sizeof(drawPayload)); +} + +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); +} + +ModResult build_controls_tab( + ModContext*, UiWindowHandle, UiElementHandle left, UiElementHandle right, void*, ModError*) { + (void)right; + + svc_ui->pane_add_section(mod_ctx, left, "Ambient Occlusion"); + add_toggle(left, "Enabled", g_cvarEnabled, "Enables the GTAO pass."); + + static const char* kQualityOptions[] = {"Low", "Medium", "High", "Ultra"}; + UiControlDesc control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_SELECT; + control.label = "Quality"; + control.help_rml = "Horizon slices and samples per pixel (XeGTAO presets: 4/8/18/54 spp)."; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = g_cvarQuality; + control.options = kQualityOptions; + control.option_count = 4; + add_control(left, control); + + control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_NUMBER; + control.label = "Radius"; + control.help_rml = "Occlusion sampling radius in world units."; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = g_cvarRadius; + control.min = 10; + control.max = 500; + control.step = 10; + add_control(left, control); + + control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_NUMBER; + control.label = "Intensity"; + control.help_rml = "How strongly occlusion darkens the scene."; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = g_cvarIntensity; + control.min = 0; + control.max = 100; + control.step = 5; + control.suffix = "%"; + add_control(left, control); + + add_toggle(left, "Half Resolution", g_cvarHalfRes, + "Computes AO at half resolution and upscales; faster, slightly softer."); + + static const char* kDebugOptions[] = {"Off", "AO", "Normals", "Depth", "Staircase"}; + control = UI_CONTROL_DESC_INIT; + control.kind = UI_CONTROL_SELECT; + control.label = "Debug View"; + control.help_rml = "AO: raw visibility as grayscale.
Normals: the view-space " + "normals the GTAO pass consumes.
Depth: the preprocessed depth " + "as a distance gradient.
Staircase: detects quantized depth - smooth " + "depth is near-black with thin triangle edges, quantized depth lights " + "up across surfaces."; + control.binding = UI_BINDING_CONFIG_VAR; + control.config_var = g_cvarDebugView; + control.options = kDebugOptions; + control.option_count = 5; + add_control(left, control); + 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 AO 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 AO 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 AO option"); + } + return MOD_OK; +} + +} // namespace + +extern "C" { + +MOD_EXPORT ModResult mod_initialize(ModError* error) { + ModResult result = svc_resource->load(mod_ctx, "preprocess_depth.wgsl", &g_preprocessSource); + if (result == MOD_OK) { + result = svc_resource->load(mod_ctx, "gtao.wgsl", &g_gtaoSource); + } + if (result == MOD_OK) { + result = svc_resource->load(mod_ctx, "denoise.wgsl", &g_denoiseSource); + } + if (result == MOD_OK) { + result = svc_resource->load(mod_ctx, "composite.wgsl", &g_compositeSource); + } + if (result != MOD_OK) { + return dusk::mods::set_error(error, result, "failed to load AO shaders"); + } + + result = register_bool_option("effectEnabled", false, g_cvarEnabled, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("quality", 2, g_cvarQuality, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("radius", 70, g_cvarRadius, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("intensity", 100, g_cvarIntensity, error); + if (result != MOD_OK) { + return result; + } + result = register_bool_option("halfRes", false, g_cvarHalfRes, error); + if (result != MOD_OK) { + return result; + } + result = register_int_option("debugMode", 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_compute_pipeline("AO preprocess depth", g_preprocessSource, "preprocess_depth", + g_preprocessPipeline, g_preprocessLayout) || + !build_compute_pipeline("AO downsample mip4", g_preprocessSource, "downsample_mip4", + g_mip4Pipeline, g_mip4Layout) || + !build_compute_pipeline("AO gtao", g_gtaoSource, "gtao", g_gtaoPipeline, g_gtaoLayout) || + !build_compute_pipeline( + "AO denoise", g_denoiseSource, "spatial_denoise", g_denoisePipeline, g_denoiseLayout)) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to create AO compute pipelines"); + } + 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 AO composite pipeline"); + } + if (!build_hilbert_lut()) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to create AO noise LUT"); + } + + GfxComputeTypeDesc computeDesc = GFX_COMPUTE_TYPE_DESC_INIT; + computeDesc.label = "AO chain"; + computeDesc.callback = on_compute; + if (svc_gfx->register_compute_type(mod_ctx, &computeDesc, &g_computeType) != MOD_OK) { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register compute type"); + } + GfxDrawTypeDesc drawDesc = GFX_DRAW_TYPE_DESC_INIT; + drawDesc.label = "AO 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_after_opaque; + if (svc_gfx->register_stage_hook( + mod_ctx, GFX_STAGE_SCENE_AFTER_OPAQUE, &stageDesc, &g_afterOpaqueHook) != MOD_OK) + { + return dusk::mods::set_error(error, MOD_ERROR, "failed to register stage hook"); + } + + UiModsPanelDesc panelDesc = UI_MODS_PANEL_DESC_INIT; + panelDesc.build = build_panel; + svc_ui->register_mods_panel(mod_ctx, &panelDesc); + + svc_log->info(mod_ctx, "ao_mod ready"); + return MOD_OK; +} + +MOD_EXPORT ModResult mod_update(ModError*) { + if (!g_loggedChain && g_chainExecuted.load(std::memory_order_acquire)) { + g_loggedChain = true; + svc_log->info(mod_ctx, "AO chain executed OK"); + } + return MOD_OK; +} + +MOD_EXPORT ModResult mod_shutdown(ModError*) { + svc_resource->free(mod_ctx, &g_preprocessSource); + svc_resource->free(mod_ctx, &g_gtaoSource); + svc_resource->free(mod_ctx, &g_denoiseSource); + svc_resource->free(mod_ctx, &g_compositeSource); + + release_targets(g_targets); + for (auto& retired : g_retiredTargets) { + release_targets(retired.targets); + } + g_retiredTargets.clear(); + + const auto releasePipeline = [](WGPUComputePipeline& pipeline) { + if (pipeline != nullptr) { + wgpuComputePipelineRelease(pipeline); + pipeline = nullptr; + } + }; + const auto releaseLayout = [](WGPUBindGroupLayout& layout) { + if (layout != nullptr) { + wgpuBindGroupLayoutRelease(layout); + layout = nullptr; + } + }; + releasePipeline(g_preprocessPipeline); + releasePipeline(g_mip4Pipeline); + releasePipeline(g_gtaoPipeline); + releasePipeline(g_denoisePipeline); + releaseLayout(g_preprocessLayout); + releaseLayout(g_mip4Layout); + releaseLayout(g_gtaoLayout); + releaseLayout(g_denoiseLayout); + if (g_compositePipeline != nullptr) { + wgpuRenderPipelineRelease(g_compositePipeline); + g_compositePipeline = nullptr; + } + if (g_compositeDebugPipeline != nullptr) { + wgpuRenderPipelineRelease(g_compositeDebugPipeline); + g_compositeDebugPipeline = nullptr; + } + releaseLayout(g_compositeLayout); + releaseLayout(g_compositeDebugLayout); + if (g_hilbertLutView != nullptr) { + wgpuTextureViewRelease(g_hilbertLutView); + g_hilbertLutView = nullptr; + } + if (g_hilbertLut != nullptr) { + wgpuTextureRelease(g_hilbertLut); + g_hilbertLut = nullptr; + } + g_cvarEnabled = g_cvarQuality = g_cvarRadius = g_cvarIntensity = 0; + g_cvarHalfRes = g_cvarDebugView = 0; + g_computeType = g_drawType = 0; + g_afterOpaqueHook = 0; + g_controlsWindow = 0; + return MOD_OK; +} +} diff --git a/tools/mod_template/CMakeLists.txt b/mods/template_mod/CMakeLists.txt similarity index 100% rename from tools/mod_template/CMakeLists.txt rename to mods/template_mod/CMakeLists.txt diff --git a/mods/template_mod/mod.json b/mods/template_mod/mod.json new file mode 100644 index 0000000000..e60714edf5 --- /dev/null +++ b/mods/template_mod/mod.json @@ -0,0 +1,7 @@ +{ + "id": "com.example.mod", + "name": "Template Mod", + "version": "1.0.0", + "author": "You", + "description": "An example Dusklight mod" +} diff --git a/tools/mod_template/res/.gitkeep b/mods/template_mod/res/.gitkeep similarity index 100% rename from tools/mod_template/res/.gitkeep rename to mods/template_mod/res/.gitkeep diff --git a/tools/mod_template/src/mod.cpp b/mods/template_mod/src/mod.cpp similarity index 100% rename from tools/mod_template/src/mod.cpp rename to mods/template_mod/src/mod.cpp diff --git a/sdk/CMakeLists.txt b/sdk/CMakeLists.txt index 533bcc22c1..8670603866 100644 --- a/sdk/CMakeLists.txt +++ b/sdk/CMakeLists.txt @@ -1,7 +1,7 @@ # Dusklight Mod SDK entry point # -# Provides game/service headers, compile definitions and version.h without -# configuring the full game tree. +# Provides game/service headers, compile definitions, version.h and WebGPU +# headers without configuring the full game tree. # # Usage (from a mod project): # add_subdirectory(/sdk dusk-sdk EXCLUDE_FROM_ALL) @@ -30,6 +30,11 @@ include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/DetectVersion.cmake") detect_version() configure_version_header() +# Provides dawn::webgpu_dawn and dawn::dawncpp_headers for public gfx service headers. +include("${CMAKE_CURRENT_SOURCE_DIR}/../extern/aurora/cmake/AuroraDependencyVersions.cmake") +set(AURORA_DAWN_PROVIDER "package" CACHE STRING "How to provide Dawn for the mod SDK") +include("${CMAKE_CURRENT_SOURCE_DIR}/../extern/aurora/cmake/AuroraDawnProvider.cmake") + # Game ABI headers & compile definitions include("${CMAKE_CURRENT_SOURCE_DIR}/../cmake/GameABIConfig.cmake") diff --git a/tools/mod_template/mod.json b/tools/mod_template/mod.json deleted file mode 100644 index 46507a87ee..0000000000 --- a/tools/mod_template/mod.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "id": "com.example.mod", - "name": "Template Mod", - "version": "1.0.0", - "author": "You", - "description": "An example Dusklight mod" -} From 409b60836bff61697f767f88d6230461e635015b Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 10 Jul 2026 00:41:04 -0600 Subject: [PATCH 04/10] Add dusk::ui::append_text --- res/rml/mods.rcss | 1 + src/dusk/ui/logs_window.cpp | 21 ++++++++------------- src/dusk/ui/pane.cpp | 4 ++-- src/dusk/ui/ui.cpp | 25 +++++++++++++++++++------ src/dusk/ui/ui.hpp | 1 + 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/res/rml/mods.rcss b/res/rml/mods.rcss index 99aa33d236..6042e35c0f 100644 --- a/res/rml/mods.rcss +++ b/res/rml/mods.rcss @@ -121,6 +121,7 @@ mod-entry .mod-entry-desc { color: rgba(224, 219, 200, 65%); max-height: 2.6em; overflow: hidden; + white-space: pre-wrap; } mod-entry .mod-entry-sub { diff --git a/src/dusk/ui/logs_window.cpp b/src/dusk/ui/logs_window.cpp index 9fdd89e50d..fc9dad96ca 100644 --- a/src/dusk/ui/logs_window.cpp +++ b/src/dusk/ui/logs_window.cpp @@ -72,15 +72,10 @@ std::string format_time(int64_t timeMs) { return fmt::format("{}.{:03}", buffer.data(), timeMs % 1000); } -void append_text(Rml::ElementDocument* doc, Rml::Element* parent, const Rml::String& text) { - parent->AppendChild(doc->CreateTextNode(text)); -} - -Rml::Element* append_span(Rml::ElementDocument* doc, Rml::Element* parent, const char* className, - const Rml::String& text) { - auto span = doc->CreateElement("span"); +Rml::Element* append_span(Rml::Element* parent, const char* className, const Rml::String& text) { + auto span = parent->GetOwnerDocument()->CreateElement("span"); span->SetClass(className, true); - append_text(doc, span.get(), text); + append_text(span.get(), text); return parent->AppendChild(std::move(span)); } @@ -270,11 +265,11 @@ Rml::Element* LogsWindow::append_log_line(const mods::log::Line& line) { elem->SetClass(level_class(line.level), true); constexpr const char* kNbsp = "\xc2\xa0"; - append_span(mDocument, elem.get(), "log-time", format_time(line.timeMs)); - append_text(mDocument, elem.get(), kNbsp); - append_span(mDocument, elem.get(), "log-mod", fmt::format("[{}]", modId)); - append_text(mDocument, elem.get(), kNbsp); - append_span(mDocument, elem.get(), "log-msg", line.message); + append_span(elem.get(), "log-time", format_time(line.timeMs)); + append_text(elem.get(), kNbsp); + append_span(elem.get(), "log-mod", fmt::format("[{}]", modId)); + append_text(elem.get(), kNbsp); + append_span(elem.get(), "log-msg", line.message); return mLinesElem->AppendChild(std::move(elem)); } diff --git a/src/dusk/ui/pane.cpp b/src/dusk/ui/pane.cpp index 01eaf1194b..2a6f2a62c3 100644 --- a/src/dusk/ui/pane.cpp +++ b/src/dusk/ui/pane.cpp @@ -166,13 +166,13 @@ bool Pane::focus() { Rml::Element* Pane::add_section(const Rml::String& text) { auto* elem = append(mRoot, "div"); elem->SetClass("section-heading", true); - elem->SetInnerRML(escape(text)); + append_text(elem, text); return elem; } Rml::Element* Pane::add_text(const Rml::String& text) { auto* elem = append(mRoot, "div"); - elem->SetInnerRML(escape(text)); + append_text(elem, text); return elem; } diff --git a/src/dusk/ui/ui.cpp b/src/dusk/ui/ui.cpp index 032937a832..0777553681 100644 --- a/src/dusk/ui/ui.cpp +++ b/src/dusk/ui/ui.cpp @@ -31,9 +31,9 @@ void load_font(const char* filename, bool fallback = false) { } bool sInitialized = false; -std::vector > sDocumentStack; +std::vector> sDocumentStack; // Documents that don't participate in the focus stack -std::vector > sPassiveDocuments; +std::vector> sPassiveDocuments; struct ScopedStyles { DocumentScope scope; @@ -173,10 +173,12 @@ void handle_event(const SDL_Event& event) noexcept { const char* name = SDL_GetGamepadName(gamepad); Rml::String content = fmt::format("{}", name ? name : "[Unknown]"); Rml::String title = "Device Connected"; - if (const char* icon = connection_state_icon(SDL_GetGamepadConnectionState(gamepad))) { + if (const char* icon = + connection_state_icon(SDL_GetGamepadConnectionState(gamepad))) + { title = fmt::format( - "{} &#x{};", title, - icon); + "{} &#x{};", + title, icon); } int batteryLevel = -1; const auto powerState = SDL_GetGamepadPowerInfo(gamepad, &batteryLevel); @@ -382,6 +384,17 @@ Rml::Element* append(Rml::Element* parent, const Rml::String& tag) noexcept { return parent->AppendChild(doc->CreateElement(tag)); } +Rml::Element* append_text(Rml::Element* parent, const Rml::String& text) noexcept { + if (parent == nullptr) { + return nullptr; + } + auto* doc = parent->GetOwnerDocument(); + if (doc == nullptr) { + return nullptr; + } + return parent->AppendChild(doc->CreateTextNode(text)); +} + NavCommand map_nav_event(const Rml::Event& event) noexcept { const auto key = static_cast( event.GetParameter("key_identifier", Rml::Input::KI_UNKNOWN)); @@ -448,7 +461,7 @@ void push_toast(Toast toast) noexcept { sToasts.push_back(std::move(toast)); } -std::vector >& get_document_stack() noexcept { +std::vector>& get_document_stack() noexcept { return sDocumentStack; } diff --git a/src/dusk/ui/ui.hpp b/src/dusk/ui/ui.hpp index daf3c3ff03..bd993ea34d 100644 --- a/src/dusk/ui/ui.hpp +++ b/src/dusk/ui/ui.hpp @@ -96,6 +96,7 @@ Document* top_document() noexcept; std::filesystem::path resource_path(const std::filesystem::path& filename) noexcept; std::string escape(std::string_view str) noexcept; Rml::Element* append(Rml::Element* parent, const Rml::String& tag) noexcept; +Rml::Element* append_text(Rml::Element* parent, const Rml::String& text) noexcept; NavCommand map_nav_event(const Rml::Event& event) noexcept; Insets safe_area_insets(Rml::Context* context) noexcept; From 84e7394d5bdfab8c3830094e017d81f15d37e175 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 10 Jul 2026 01:08:49 -0600 Subject: [PATCH 05/10] Dynamic shadows demo mod --- CMakeLists.txt | 1 + .../include/JSystem/J3DGraphBase/J3DShape.h | 1 + mods/shadow_mod/CMakeLists.txt | 20 + mods/shadow_mod/mod.json | 7 + mods/shadow_mod/res/shadow.wgsl | 262 ++++ mods/shadow_mod/src/mod.cpp | 1098 +++++++++++++++++ 6 files changed, 1389 insertions(+) create mode 100644 mods/shadow_mod/CMakeLists.txt create mode 100644 mods/shadow_mod/mod.json create mode 100644 mods/shadow_mod/res/shadow.wgsl create mode 100644 mods/shadow_mod/src/mod.cpp 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; +} +} From 50565b37fc3240ca152cab4c77b64f0b8d37b817 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 10 Jul 2026 01:45:05 -0600 Subject: [PATCH 06/10] Fix mod demo CI linkage and signing --- cmake/ModSDK.cmake | 6 ++++++ include/d/d_com_inf_game.h | 2 +- include/d/d_kankyo.h | 2 +- libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h | 3 ++- libs/JSystem/include/JSystem/J3DGraphBase/J3DSys.h | 3 ++- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index c320dcb5f4..c4fc1f560d 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -64,6 +64,9 @@ function(add_mod target_name) WINDOWS_EXPORT_ALL_SYMBOLS OFF) target_compile_features(${target_name} PRIVATE cxx_std_20) target_link_libraries(${target_name} PRIVATE dusklight_game_headers) + if (TARGET dawn::webgpu_dawn) + target_link_libraries(${target_name} PRIVATE dawn::webgpu_dawn) + endif () if (NOT TARGET dusklight) # Apply global compile options for out-of-tree mod builds @@ -255,6 +258,9 @@ function(install_bundled_mods) install(DIRECTORY "${_stage}/" DESTINATION "${_bundle_dir}/Contents/Resources/mods/${_id}") install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - \"${_bundle_dir}/Contents/Resources/mods/${_id}/${_lib_name}\" COMMAND_ERROR_IS_FATAL ANY)") endforeach () + if (TARGET crashpad_handler) + install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - \"${_bundle_dir}/Contents/MacOS/$\" COMMAND_ERROR_IS_FATAL ANY)") + endif () install(CODE "execute_process(COMMAND /usr/bin/codesign --force --sign - --entitlements \"${DUSK_ENTITLEMENTS}\" \"${_bundle_dir}\" COMMAND_ERROR_IS_FATAL ANY)") endif () return () diff --git a/include/d/d_com_inf_game.h b/include/d/d_com_inf_game.h index b66c1ee8e8..562b4ce33e 100644 --- a/include/d/d_com_inf_game.h +++ b/include/d/d_com_inf_game.h @@ -1049,7 +1049,7 @@ public: STATIC_ASSERT(122384 == sizeof(dComIfG_inf_c)); -extern dComIfG_inf_c g_dComIfG_gameInfo; +DUSK_GAME_EXTERN dComIfG_inf_c g_dComIfG_gameInfo; extern GXColor g_blackColor; extern GXColor g_clearColor; extern GXColor g_whiteColor; diff --git a/include/d/d_kankyo.h b/include/d/d_kankyo.h index 06c8f0eb56..fb3d0435b0 100644 --- a/include/d/d_kankyo.h +++ b/include/d/d_kankyo.h @@ -471,7 +471,7 @@ public: /* 0x130C */ u8 staffroll_next_timer; }; // Size: 0x1310 -extern dScnKy_env_light_c g_env_light; +DUSK_GAME_EXTERN dScnKy_env_light_c g_env_light; STATIC_ASSERT(sizeof(dScnKy_env_light_c) == 4880); diff --git a/libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h b/libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h index 28cb9f7fe6..ea13c0600c 100644 --- a/libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h +++ b/libs/JSystem/include/JSystem/J3DGraphBase/J3DShape.h @@ -5,6 +5,7 @@ #include "JSystem/J3DAssert.h" #include "JSystem/J3DGraphBase/J3DFifo.h" #include "JSystem/JMath/JMath.h" +#include "global.h" #include #include "dusk/endian_gx.hpp" @@ -203,7 +204,7 @@ public: static void resetVcdVatCache() { sOldVcdVatCmd = NULL; } - static void* sOldVcdVatCmd; + static DUSK_GAME_DATA void* sOldVcdVatCmd; static bool sEnvelopeFlag; private: diff --git a/libs/JSystem/include/JSystem/J3DGraphBase/J3DSys.h b/libs/JSystem/include/JSystem/J3DGraphBase/J3DSys.h index 8c4bbd06e3..ddc961f73f 100644 --- a/libs/JSystem/include/JSystem/J3DGraphBase/J3DSys.h +++ b/libs/JSystem/include/JSystem/J3DGraphBase/J3DSys.h @@ -8,6 +8,7 @@ #include "JSystem/JMath/JMath.h" #include "dusk/frame_interpolation.h" #include "dusk/endian.h" +#include "global.h" enum J3DSysDrawBuf { /* 0x0 */ J3DSysDrawBuf_Opa, @@ -208,6 +209,6 @@ struct J3DSys { }; extern u32 j3dDefaultViewNo; -extern J3DSys j3dSys; +DUSK_GAME_EXTERN J3DSys j3dSys; #endif /* J3DSYS_H */ From 2fcbebcd51fa05b753e78048f666f62aa03bedfb Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 10 Jul 2026 01:52:17 -0600 Subject: [PATCH 07/10] Limit Dawn mod linkage to Windows --- cmake/ModSDK.cmake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index c4fc1f560d..b7241e1e22 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -64,7 +64,7 @@ function(add_mod target_name) WINDOWS_EXPORT_ALL_SYMBOLS OFF) target_compile_features(${target_name} PRIVATE cxx_std_20) target_link_libraries(${target_name} PRIVATE dusklight_game_headers) - if (TARGET dawn::webgpu_dawn) + if (WIN32 AND TARGET dawn::webgpu_dawn) target_link_libraries(${target_name} PRIVATE dawn::webgpu_dawn) endif () From 157bbfb4c9354588bd8092e2e88a941e1372b63b Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 10 Jul 2026 02:04:59 -0600 Subject: [PATCH 08/10] Mark full game targets as ABI exporters --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 2af48dbcb0..f0f80651ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -304,6 +304,7 @@ source_group("dusklight" FILES ${DUSK_FILES} ${DUSK_HTTP_BACKEND_FILES}) include(cmake/GameABIConfig.cmake) find_package(Threads REQUIRED) +set(GAME_COMPILE_DEFS DUSK_BUILDING_GAME=1) set(GAME_LIBS aurora::core aurora::gx aurora::gd aurora::si aurora::vi aurora::pad aurora::mtx aurora::os aurora::dvd aurora::card freeverb cxxopts::cxxopts absl::flat_hash_map nlohmann_json::nlohmann_json TracyClient fmt::fmt Threads::Threads zstd::libzstd dusklight_game_headers) From 867ec7fbab9d36c8eb0c11ba633bce37a325a13a Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 10 Jul 2026 09:42:25 -0600 Subject: [PATCH 09/10] Forward WebGPU exports through Dusklight --- cmake/ModSDK.cmake | 3 --- cmake/SymbolManifest.cmake | 2 +- cmake/WindowsExports.cmake | 11 +++++++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/cmake/ModSDK.cmake b/cmake/ModSDK.cmake index b7241e1e22..d08a2ae221 100644 --- a/cmake/ModSDK.cmake +++ b/cmake/ModSDK.cmake @@ -64,9 +64,6 @@ function(add_mod target_name) WINDOWS_EXPORT_ALL_SYMBOLS OFF) target_compile_features(${target_name} PRIVATE cxx_std_20) target_link_libraries(${target_name} PRIVATE dusklight_game_headers) - if (WIN32 AND TARGET dawn::webgpu_dawn) - target_link_libraries(${target_name} PRIVATE dawn::webgpu_dawn) - endif () if (NOT TARGET dusklight) # Apply global compile options for out-of-tree mod builds diff --git a/cmake/SymbolManifest.cmake b/cmake/SymbolManifest.cmake index ffaae9177e..927040a47f 100644 --- a/cmake/SymbolManifest.cmake +++ b/cmake/SymbolManifest.cmake @@ -2,7 +2,7 @@ include_guard(GLOBAL) get_filename_component(_SYMBOL_MANIFEST_CMAKE_DIR "${CMAKE_CURRENT_LIST_FILE}" DIRECTORY) -set(_SYMGEN_VERSION "1.1.0") +set(_SYMGEN_VERSION "1.1.1") set(_SYMGEN_RELEASE_BASE_URL "https://github.com/encounter/symgen/releases/download/v${_SYMGEN_VERSION}") set(SYMGEN_PATH "" CACHE FILEPATH "Path to a symgen executable; empty downloads the pinned release") mark_as_advanced(SYMGEN_PATH) diff --git a/cmake/WindowsExports.cmake b/cmake/WindowsExports.cmake index 86dc9414f2..3010c82f25 100644 --- a/cmake/WindowsExports.cmake +++ b/cmake/WindowsExports.cmake @@ -37,6 +37,16 @@ function(setup_windows_exports target) endif () endforeach () + set(_forward_args) + if (TARGET dawn::webgpu_dawn) + get_target_property(_dawn_type dawn::webgpu_dawn TYPE) + if (_dawn_type STREQUAL "SHARED_LIBRARY") + list(APPEND _forward_args + --forward-dll "$" + --forward-sym-prefix wgpu) + endif () + endif () + # Generate curated exports list from the main binary set(_def "${CMAKE_BINARY_DIR}/${_config_subdir}dusklight_exports.def") add_custom_command(TARGET ${target} PRE_LINK @@ -50,6 +60,7 @@ function(setup_windows_exports target) --exclude asan_options --max-exports 58000 ${_sdk_args} + ${_forward_args} COMMENT "Generating dusklight exports" VERBATIM) target_link_options(${target} PRIVATE "/DEF:${_def}") From 7113584632878a358d0bfae308776bf17f67a1b3 Mon Sep 17 00:00:00 2001 From: Luke Street Date: Fri, 10 Jul 2026 17:42:24 -0600 Subject: [PATCH 10/10] SlotMap abstraction & log fix --- src/dusk/mods/svc/config.cpp | 158 ++++++++++++--------------- src/dusk/mods/svc/gfx.cpp | 188 ++++++++++++--------------------- src/dusk/mods/svc/host.cpp | 49 +++++---- src/dusk/mods/svc/overlay.cpp | 79 +++++++------- src/dusk/mods/svc/slot_map.hpp | 188 +++++++++++++++++++++++++++++++++ src/dusk/mods/svc/ui.cpp | 144 +++++++------------------ src/dusk/ui/logs_window.cpp | 20 ++-- 7 files changed, 445 insertions(+), 381 deletions(-) create mode 100644 src/dusk/mods/svc/slot_map.hpp diff --git a/src/dusk/mods/svc/config.cpp b/src/dusk/mods/svc/config.cpp index d919c0ab1f..bf943d61b8 100644 --- a/src/dusk/mods/svc/config.cpp +++ b/src/dusk/mods/svc/config.cpp @@ -1,6 +1,7 @@ #include "config.hpp" #include "registry.hpp" +#include "slot_map.hpp" #include "aurora/lib/logging.hpp" #include "dusk/config.hpp" @@ -15,7 +16,6 @@ #include #include #include -#include #include namespace dusk::mods::svc { @@ -23,25 +23,22 @@ namespace { aurora::Module Log("dusk::mods::config"); -struct ModConfigVarEntry { - uint64_t handle = 0; - ConfigVarType type = CONFIG_VAR_BOOL; - std::unique_ptr var; +enum class ConfigSlotKind : uint8_t { + Var, + Subscription, }; -struct ModConfigSubscription { - uint64_t handle = 0; +struct ConfigSlot { + ConfigSlotKind kind = ConfigSlotKind::Var; + // Var payload + ConfigVarType type = CONFIG_VAR_BOOL; + std::unique_ptr var; + // Subscription payload uint64_t varHandle = 0; config::Subscription coreSubscription = 0; }; -struct ModConfigRecord { - std::vector vars; - std::vector subscriptions; -}; - -std::unordered_map s_modConfig; -uint64_t s_nextHandle = 1; +SlotMap s_slots; bool s_dirty = false; std::chrono::steady_clock::time_point s_lastSave{}; constexpr std::chrono::seconds kSaveDebounce{2}; @@ -130,17 +127,13 @@ bool valid_var_fragment(const char* name) { } config::ConfigVarBase* find_var(LoadedMod& mod, const uint64_t handle, uint32_t expectedType) { - const auto recordIt = s_modConfig.find(&mod); - if (recordIt == s_modConfig.end()) { + const auto* entry = s_slots.find_owned(handle, mod); + if (entry == nullptr || entry->value.kind != ConfigSlotKind::Var || + entry->value.type != expectedType) + { return nullptr; } - const auto& vars = recordIt->second.vars; - const auto entry = - std::ranges::find_if(vars, [&](const auto& e) { return e.handle == handle; }); - if (entry == vars.end() || entry->type != expectedType) { - return nullptr; - } - return entry->var.get(); + return entry->value.var.get(); } template @@ -154,17 +147,17 @@ ConfigVar* find_typed_var(ModContext* context, ConfigVarHandle handle, uint32 } void config_remove_mod(LoadedMod& mod) { - const auto it = s_modConfig.find(&mod); - if (it == s_modConfig.end()) { - return; + const auto entries = s_slots.take_all(mod); + for (const auto& entry : entries) { + if (entry.value.kind == ConfigSlotKind::Subscription) { + config::unsubscribe(entry.value.coreSubscription); + } } - for (const auto& sub : it->second.subscriptions) { - config::unsubscribe(sub.coreSubscription); + for (const auto& entry : entries) { + if (entry.value.kind == ConfigSlotKind::Var) { + config::unregister(*entry.value.var); + } } - for (const auto& entry : it->second.vars) { - config::unregister(*entry.var); - } - s_modConfig.erase(it); } ModResult config_register_var( @@ -190,16 +183,16 @@ ModResult config_register_var( std::unique_ptr var; switch (desc->type) { case CONFIG_VAR_BOOL: - var = std::make_unique >(fullName, desc->default_bool); + var = std::make_unique>(fullName, desc->default_bool); break; case CONFIG_VAR_INT: - var = std::make_unique >(fullName, desc->default_int); + var = std::make_unique>(fullName, desc->default_int); break; case CONFIG_VAR_FLOAT: - var = std::make_unique >(fullName, desc->default_float); + var = std::make_unique>(fullName, desc->default_float); break; case CONFIG_VAR_STRING: - var = std::make_unique >( + var = std::make_unique>( fullName, desc->default_string != nullptr ? desc->default_string : ""); break; default: @@ -211,13 +204,10 @@ ModResult config_register_var( // reads the value right after registration anyway. config::Register(*var); - auto& record = s_modConfig[mod]; - auto& entry = record.vars.emplace_back(); - entry.handle = s_nextHandle++; - entry.type = desc->type; - entry.var = std::move(var); + const auto handle = s_slots.emplace( + *mod, ConfigSlot{.kind = ConfigSlotKind::Var, .type = desc->type, .var = std::move(var)}); if (outHandle != nullptr) { - *outHandle = entry.handle; + *outHandle = handle; } return MOD_OK; } @@ -227,29 +217,26 @@ ModResult config_unregister_var(ModContext* context, ConfigVarHandle var) { if (mod == nullptr || var == 0) { return MOD_INVALID_ARGUMENT; } - const auto recordIt = s_modConfig.find(mod); - if (recordIt != s_modConfig.end()) { - auto& record = recordIt->second; - const auto entry = - std::ranges::find_if(record.vars, [&](const auto& e) { return e.handle == var; }); - if (entry != record.vars.end()) { - std::erase_if(record.subscriptions, [&](const ModConfigSubscription& sub) { - if (sub.varHandle != var) { - return false; - } - config::unsubscribe(sub.coreSubscription); - return true; - }); - - // The persisted value is stashed and restored by a future registration of the - // same name. - config::unregister(*entry->var); - record.vars.erase(entry); - return MOD_OK; - } + const auto* entry = s_slots.find_owned(var, *mod); + if (entry == nullptr || entry->value.kind != ConfigSlotKind::Var) { + Log.error("[{}] config unregister failed: unknown handle {}", mod->metadata.id, var); + return MOD_INVALID_ARGUMENT; } - Log.error("[{}] config unregister failed: unknown handle {}", mod->metadata.id, var); - return MOD_INVALID_ARGUMENT; + + // Only the owning mod can (currently) subscribe to a var + std::vector bound; + s_slots.for_each([&](const uint64_t handle, const auto& e) { + if (e.value.kind == ConfigSlotKind::Subscription && e.value.varHandle == var) { + bound.push_back(handle); + } + }); + for (const auto handle : bound) { + config::unsubscribe(s_slots.take(handle)->value.coreSubscription); + } + + // The persisted value is stashed and restored by a future registration of the same name. + config::unregister(*s_slots.take(var)->value.var); + return MOD_OK; } ModResult config_get_bool(ModContext* context, ConfigVarHandle var, bool* outValue) { @@ -361,22 +348,13 @@ ModResult config_subscribe(ModContext* context, ConfigVarHandle var, ConfigChang if (mod == nullptr || var == 0 || callback == nullptr) { return MOD_INVALID_ARGUMENT; } - const auto recordIt = s_modConfig.find(mod); - if (recordIt == s_modConfig.end()) { - return MOD_INVALID_ARGUMENT; - } - auto& record = recordIt->second; - const auto entry = - std::ranges::find_if(record.vars, [&](const auto& e) { return e.handle == var; }); - if (entry == record.vars.end()) { + const auto* entry = s_slots.find_owned(var, *mod); + if (entry == nullptr || entry->value.kind != ConfigSlotKind::Var) { return MOD_INVALID_ARGUMENT; } - auto& sub = record.subscriptions.emplace_back(); - sub.handle = s_nextHandle++; - sub.varHandle = var; - sub.coreSubscription = config::subscribe(entry->var->getName(), - [modPtr = mod, callback, userData, varHandle = var, type = entry->type]( + const auto coreSubscription = config::subscribe(entry->value.var->getName(), + [modPtr = mod, callback, userData, varHandle = var, type = entry->value.type]( config::ConfigVarBase& varBase, const void* previous) { const ConfigVarValue previousValue = translate_previous(type, previous); std::string stringStorage; @@ -390,8 +368,13 @@ ModResult config_subscribe(ModContext* context, ConfigVarHandle var, ConfigChang fail_mod(*modPtr, MOD_ERROR, "Unknown exception in config change callback"); } }); + const auto handle = s_slots.emplace(*mod, ConfigSlot{ + .kind = ConfigSlotKind::Subscription, + .varHandle = var, + .coreSubscription = coreSubscription, + }); if (outHandle != nullptr) { - *outHandle = sub.handle; + *outHandle = handle; } return MOD_OK; } @@ -401,19 +384,14 @@ ModResult config_unsubscribe(ModContext* context, ConfigSubscriptionHandle handl if (mod == nullptr || handle == 0) { return MOD_INVALID_ARGUMENT; } - const auto recordIt = s_modConfig.find(mod); - if (recordIt != s_modConfig.end()) { - auto& subscriptions = recordIt->second.subscriptions; - const auto sub = - std::ranges::find_if(subscriptions, [&](const auto& s) { return s.handle == handle; }); - if (sub != subscriptions.end()) { - config::unsubscribe(sub->coreSubscription); - subscriptions.erase(sub); - return MOD_OK; - } + const auto* entry = s_slots.find_owned(handle, *mod); + if (entry == nullptr || entry->value.kind != ConfigSlotKind::Subscription) { + Log.error("[{}] config unsubscribe failed: unknown handle {}", mod->metadata.id, handle); + return MOD_INVALID_ARGUMENT; } - Log.error("[{}] config unsubscribe failed: unknown handle {}", mod->metadata.id, handle); - return MOD_INVALID_ARGUMENT; + config::unsubscribe(entry->value.coreSubscription); + s_slots.erase(handle); + return MOD_OK; } constexpr ConfigService s_configService{ diff --git a/src/dusk/mods/svc/gfx.cpp b/src/dusk/mods/svc/gfx.cpp index 7fa472454d..e14b587f5e 100644 --- a/src/dusk/mods/svc/gfx.cpp +++ b/src/dusk/mods/svc/gfx.cpp @@ -1,4 +1,5 @@ #include "registry.hpp" +#include "slot_map.hpp" #include "aurora/lib/logging.hpp" #include "dusk/gfx.hpp" @@ -35,9 +36,6 @@ enum class GfxStreamBuffer : uint8_t { struct GfxSlot { GfxSlotKind kind = GfxSlotKind::DrawType; - uint32_t generation = 1; - bool alive = false; - LoadedMod* owner = nullptr; ModContext* ownerContext = nullptr; std::string ownerId; void* userData = nullptr; @@ -60,71 +58,37 @@ struct WorkerFailure { }; std::mutex s_mutex; -std::vector s_slots; -std::vector s_freeSlots; +using GfxSlotMap = svc::SlotMap; +GfxSlotMap s_slots; std::vector s_workerFailures; bool s_modOffscreenOpen = false; -constexpr uint64_t make_handle(uint32_t index, uint32_t generation) { - return static_cast(generation) << 32 | index; +GfxSlotMap::Entry* resolve_entry_locked(uint64_t handle, GfxSlotKind kind) { + auto* entry = s_slots.find(handle); + if (entry == nullptr || entry->value.kind != kind) { + return nullptr; + } + return entry; } GfxSlot* resolve_slot_locked(uint64_t handle, GfxSlotKind kind) { - const auto index = static_cast(handle & 0xFFFFFFFFu); - const auto generation = static_cast(handle >> 32); - if (handle == 0 || index >= s_slots.size()) { - return nullptr; - } - auto& slot = s_slots[index]; - if (!slot.alive || slot.generation != generation || slot.kind != kind) { - return nullptr; - } - return &slot; + auto* entry = resolve_entry_locked(handle, kind); + return entry != nullptr ? &entry->value : nullptr; } GfxSlot* resolve_owned_slot_locked(LoadedMod& mod, uint64_t handle, GfxSlotKind kind) { - auto* slot = resolve_slot_locked(handle, kind); - if (slot == nullptr || slot->owner != &mod) { + auto* entry = s_slots.find_owned(handle, mod); + if (entry == nullptr || entry->value.kind != kind) { return nullptr; } - return slot; + return &entry->value; } -uint32_t alloc_slot_locked() { - if (!s_freeSlots.empty()) { - const auto index = s_freeSlots.back(); - s_freeSlots.pop_back(); - return index; - } - const auto index = static_cast(s_slots.size()); - s_slots.emplace_back(); - return index; -} - -void free_slot_locked(uint32_t index) { - auto& slot = s_slots[index]; - slot.alive = false; - ++slot.generation; - slot.owner = nullptr; - slot.ownerContext = nullptr; - slot.ownerId.clear(); - slot.userData = nullptr; - slot.drawFn = nullptr; - slot.auroraDrawId = aurora::gfx::InvalidDrawType; - slot.stageFn = nullptr; - slot.stage = GFX_STAGE_SCENE_AFTER_TERRAIN; - slot.computeFn = nullptr; - slot.auroraTaskId = aurora::gfx::InvalidEncoderTask; - s_freeSlots.push_back(index); -} - -void collect_mod_slots_locked(LoadedMod* owner, std::vector& drawIds, +void collect_mod_slots_locked(LoadedMod& owner, std::vector& drawIds, std::vector& taskIds) { - for (uint32_t i = 0; i < s_slots.size(); ++i) { - auto& slot = s_slots[i]; - if (!slot.alive || slot.owner != owner) { - continue; - } + auto entries = s_slots.take_all(owner); + for (auto& entry : entries) { + const auto& slot = entry.value; if (slot.kind == GfxSlotKind::DrawType && slot.auroraDrawId != aurora::gfx::InvalidDrawType) { drawIds.push_back(slot.auroraDrawId); @@ -133,7 +97,6 @@ void collect_mod_slots_locked(LoadedMod* owner, std::vectordrawFn; - userData = slot->userData; - modContext = slot->ownerContext; - owner = slot->owner; - ownerId = slot->ownerId; + const auto& slot = entry->value; + fn = slot.drawFn; + userData = slot.userData; + modContext = slot.ownerContext; + owner = entry->owner; + ownerId = slot.ownerId; } GfxDrawContext drawContext{ @@ -200,7 +164,7 @@ void draw_trampoline(const aurora::gfx::DrawContext& ctx, const wgpu::RenderPass .modId = std::move(ownerId), .message = std::move(failure), }; - collect_mod_slots_locked(owner, record.drawIds, record.taskIds); + collect_mod_slots_locked(*owner, record.drawIds, record.taskIds); s_workerFailures.push_back(std::move(record)); } @@ -214,15 +178,16 @@ void compute_trampoline(const aurora::gfx::EncoderTaskContext& ctx, const wgpu:: std::string ownerId; { std::lock_guard lock{s_mutex}; - auto* slot = resolve_slot_locked(handle, GfxSlotKind::ComputeType); - if (slot == nullptr) { + auto* entry = resolve_entry_locked(handle, GfxSlotKind::ComputeType); + if (entry == nullptr) { return; } - fn = slot->computeFn; - userData = slot->userData; - modContext = slot->ownerContext; - owner = slot->owner; - ownerId = slot->ownerId; + const auto& slot = entry->value; + fn = slot.computeFn; + userData = slot.userData; + modContext = slot.ownerContext; + owner = entry->owner; + ownerId = slot.ownerId; } GfxComputeContext computeContext{ @@ -251,7 +216,7 @@ void compute_trampoline(const aurora::gfx::EncoderTaskContext& ctx, const wgpu:: .modId = std::move(ownerId), .message = std::move(failure), }; - collect_mod_slots_locked(owner, record.drawIds, record.taskIds); + collect_mod_slots_locked(*owner, record.drawIds, record.taskIds); s_workerFailures.push_back(std::move(record)); } @@ -264,16 +229,13 @@ ModResult gfx_register_draw_type( uint64_t handle = 0; { std::lock_guard lock{s_mutex}; - const auto index = alloc_slot_locked(); - auto& slot = s_slots[index]; - slot.kind = GfxSlotKind::DrawType; - slot.alive = true; - slot.owner = &mod; - slot.ownerContext = mod.context.get(); - slot.ownerId = mod.metadata.id; - slot.userData = userData; - slot.drawFn = draw; - handle = make_handle(index, slot.generation); + handle = s_slots.emplace(mod, GfxSlot{ + .kind = GfxSlotKind::DrawType, + .ownerContext = mod.context.get(), + .ownerId = mod.metadata.id, + .userData = userData, + .drawFn = draw, + }); } const auto auroraId = aurora::gfx::register_draw_type(aurora::gfx::DrawTypeDescriptor{ @@ -283,9 +245,7 @@ ModResult gfx_register_draw_type( }); if (auroraId == aurora::gfx::InvalidDrawType) { std::lock_guard lock{s_mutex}; - if (resolve_owned_slot_locked(mod, handle, GfxSlotKind::DrawType) != nullptr) { - free_slot_locked(static_cast(handle & 0xFFFFFFFFu)); - } + s_slots.erase_owned(handle, mod); return MOD_ERROR; } @@ -308,7 +268,7 @@ ModResult gfx_unregister_draw_type(LoadedMod& mod, uint64_t handle) { return MOD_INVALID_ARGUMENT; } auroraId = slot->auroraDrawId; - free_slot_locked(static_cast(handle & 0xFFFFFFFFu)); + s_slots.erase_owned(handle, mod); } aurora::gfx::unregister_draw_type(auroraId); return MOD_OK; @@ -359,17 +319,14 @@ ModResult gfx_register_stage_hook( LoadedMod& mod, GfxStage stage, GfxStageFn callback, void* userData, uint64_t& outHandle) { outHandle = 0; std::lock_guard lock{s_mutex}; - const auto index = alloc_slot_locked(); - auto& slot = s_slots[index]; - slot.kind = GfxSlotKind::StageHook; - slot.alive = true; - slot.owner = &mod; - slot.ownerContext = mod.context.get(); - slot.ownerId = mod.metadata.id; - slot.userData = userData; - slot.stageFn = callback; - slot.stage = stage; - outHandle = make_handle(index, slot.generation); + outHandle = s_slots.emplace(mod, GfxSlot{ + .kind = GfxSlotKind::StageHook, + .ownerContext = mod.context.get(), + .ownerId = mod.metadata.id, + .userData = userData, + .stageFn = callback, + .stage = stage, + }); return MOD_OK; } @@ -379,7 +336,7 @@ ModResult gfx_unregister_stage_hook(LoadedMod& mod, uint64_t handle) { if (slot == nullptr) { return MOD_INVALID_ARGUMENT; } - free_slot_locked(static_cast(handle & 0xFFFFFFFFu)); + s_slots.erase_owned(handle, mod); return MOD_OK; } @@ -429,16 +386,13 @@ ModResult gfx_register_compute_type( uint64_t handle = 0; { std::lock_guard lock{s_mutex}; - const auto index = alloc_slot_locked(); - auto& slot = s_slots[index]; - slot.kind = GfxSlotKind::ComputeType; - slot.alive = true; - slot.owner = &mod; - slot.ownerContext = mod.context.get(); - slot.ownerId = mod.metadata.id; - slot.userData = userData; - slot.computeFn = callback; - handle = make_handle(index, slot.generation); + handle = s_slots.emplace(mod, GfxSlot{ + .kind = GfxSlotKind::ComputeType, + .ownerContext = mod.context.get(), + .ownerId = mod.metadata.id, + .userData = userData, + .computeFn = callback, + }); } const auto auroraId = @@ -449,9 +403,7 @@ ModResult gfx_register_compute_type( }); if (auroraId == aurora::gfx::InvalidEncoderTask) { std::lock_guard lock{s_mutex}; - if (resolve_owned_slot_locked(mod, handle, GfxSlotKind::ComputeType) != nullptr) { - free_slot_locked(static_cast(handle & 0xFFFFFFFFu)); - } + s_slots.erase_owned(handle, mod); return MOD_ERROR; } @@ -474,7 +426,7 @@ ModResult gfx_unregister_compute_type(LoadedMod& mod, uint64_t handle) { return MOD_INVALID_ARGUMENT; } auroraId = slot->auroraTaskId; - free_slot_locked(static_cast(handle & 0xFFFFFFFFu)); + s_slots.erase_owned(handle, mod); } aurora::gfx::unregister_encoder_task_type(auroraId); return MOD_OK; @@ -510,18 +462,18 @@ void gfx_run_stage( std::vector entries; { std::lock_guard lock{s_mutex}; - for (uint32_t i = 0; i < s_slots.size(); ++i) { - const auto& slot = s_slots[i]; - if (slot.alive && slot.kind == GfxSlotKind::StageHook && slot.stage == stage) { + s_slots.for_each([&](uint64_t handle, const auto& slotEntry) { + const auto& slot = slotEntry.value; + if (slot.kind == GfxSlotKind::StageHook && slot.stage == stage) { entries.push_back(StageEntry{ - .handle = make_handle(i, slot.generation), + .handle = handle, .fn = slot.stageFn, .userData = slot.userData, .context = slot.ownerContext, - .owner = slot.owner, + .owner = slotEntry.owner, }); } - } + }); } if (entries.empty()) { return; @@ -600,7 +552,7 @@ void gfx_remove_mod(LoadedMod& mod) { std::vector taskIds; { std::lock_guard lock{s_mutex}; - collect_mod_slots_locked(&mod, drawIds, taskIds); + collect_mod_slots_locked(mod, drawIds, taskIds); } if (drawIds.empty() && taskIds.empty()) { return; diff --git a/src/dusk/mods/svc/host.cpp b/src/dusk/mods/svc/host.cpp index 5904903ee7..0199a7865a 100644 --- a/src/dusk/mods/svc/host.cpp +++ b/src/dusk/mods/svc/host.cpp @@ -1,4 +1,5 @@ #include "registry.hpp" +#include "slot_map.hpp" #include "dusk/mods/loader/loader.hpp" #include "dusk/mods/manifest.hpp" @@ -63,14 +64,13 @@ const char* host_mod_dir(ModContext* context) { } struct LifecycleWatcher { - uint64_t handle = 0; - LoadedMod* owner = nullptr; ModLifecycleFn fn = nullptr; void* userData = nullptr; + uint64_t order = 0; }; -std::vector s_watchers; -uint64_t s_nextWatchHandle = 1; +SlotMap s_watchers; +uint64_t s_nextWatchOrder = 0; ModResult host_watch_mod_lifecycle( ModContext* context, ModLifecycleFn fn, void* userData, uint64_t* outHandle) { @@ -78,8 +78,8 @@ ModResult host_watch_mod_lifecycle( if (mod == nullptr || fn == nullptr || outHandle == nullptr) { return MOD_INVALID_ARGUMENT; } - const auto handle = s_nextWatchHandle++; - s_watchers.push_back({handle, mod, fn, userData}); + const auto handle = s_watchers.emplace( + *mod, LifecycleWatcher{.fn = fn, .userData = userData, .order = s_nextWatchOrder++}); *outHandle = handle; return MOD_OK; } @@ -89,32 +89,41 @@ ModResult host_unwatch_mod_lifecycle(ModContext* context, const uint64_t handle) if (mod == nullptr) { return MOD_INVALID_ARGUMENT; } - const auto erased = std::erase_if(s_watchers, - [&](const LifecycleWatcher& w) { return w.handle == handle && w.owner == mod; }); - return erased != 0 ? MOD_OK : MOD_INVALID_ARGUMENT; + return s_watchers.erase_owned(handle, *mod) ? MOD_OK : MOD_INVALID_ARGUMENT; } void host_mod_detached(LoadedMod& mod) { // The subject's own watches go first: a mod is never notified about its own teardown. - std::erase_if(s_watchers, [&](const LifecycleWatcher& w) { return w.owner == &mod; }); + s_watchers.erase_all(mod); - // Iterate a snapshot: callbacks may watch/unwatch, and a failing callback erases the - // failing mod's services. - const auto snapshot = s_watchers; - for (const auto& watcher : snapshot) { - const bool alive = std::ranges::any_of( - s_watchers, [&](const LifecycleWatcher& w) { return w.handle == watcher.handle; }); - if (!alive) { + // Iterate a snapshot in registration order: callbacks may watch/unwatch, and a failing + // callback erases the failing mod's services. + struct PendingNotify { + uint64_t order; + uint64_t handle; + }; + std::vector snapshot; + s_watchers.for_each([&](const uint64_t handle, const auto& entry) { + snapshot.push_back({.order = entry.value.order, .handle = handle}); + }); + std::ranges::sort(snapshot, {}, &PendingNotify::order); + + for (const auto& pending : snapshot) { + const auto* entry = s_watchers.find(pending.handle); + if (entry == nullptr) { continue; } + // Do not retain pointers into SlotMap across a callback that may mutate it. + auto* owner = entry->owner; + const auto watcher = entry->value; try { - watcher.fn(watcher.owner->context.get(), mod.context.get(), mod.metadata.id.c_str(), + watcher.fn(owner->context.get(), mod.context.get(), mod.metadata.id.c_str(), MOD_LIFECYCLE_DETACHED, watcher.userData); } catch (const std::exception& e) { - fail_mod(*watcher.owner, MOD_ERROR, + fail_mod(*owner, MOD_ERROR, fmt::format("Exception in mod lifecycle callback: {}", e.what())); } catch (...) { - fail_mod(*watcher.owner, MOD_ERROR, "Unknown exception in mod lifecycle callback"); + fail_mod(*owner, MOD_ERROR, "Unknown exception in mod lifecycle callback"); } } } diff --git a/src/dusk/mods/svc/overlay.cpp b/src/dusk/mods/svc/overlay.cpp index 13cab1ac0f..44eef6ab17 100644 --- a/src/dusk/mods/svc/overlay.cpp +++ b/src/dusk/mods/svc/overlay.cpp @@ -1,10 +1,12 @@ #include "registry.hpp" +#include "slot_map.hpp" #include "aurora/dvd.h" #include "aurora/lib/logging.hpp" #include "dusk/mods/loader/loader.hpp" #include "mods/svc/overlay.h" +#include #include #include #include @@ -33,15 +35,15 @@ std::unordered_map s_overlayFiles; uintptr_t s_nextOverlayId = 1; std::mutex s_overlayMutex; -struct RuntimeOverlayEntry { - uint64_t handle = 0; +struct RuntimeOverlaySlot { std::string discPath; std::string bundlePath; // bundle-backed if non-empty std::shared_ptr> buffer; // buffer-backed otherwise size_t size = 0; + uint64_t order = 0; }; -std::unordered_map> s_runtimeOverlays; -uint64_t s_nextRuntimeHandle = 1; +SlotMap s_runtimeOverlays; +uint64_t s_nextRuntimeOrder = 0; bool s_overlaysDirty = false; // Aurora matches overlay paths against the disc case-insensitively and later entries win, so @@ -84,20 +86,25 @@ void find_overlay_files(std::vector& files, LoadedMod& mod, void append_runtime_overlays(std::vector& files, LoadedMod& mod, std::unordered_map& claims) { - const auto it = s_runtimeOverlays.find(&mod); - if (it == s_runtimeOverlays.end()) { - return; - } - - for (const auto& entry : it->second) { - const auto id = s_nextOverlayId++; - if (entry.buffer != nullptr) { - s_overlayFiles.emplace(id, OverlayFileData{{}, nullptr, entry.buffer}); - } else { - s_overlayFiles.emplace(id, OverlayFileData{entry.bundlePath, mod.bundle, nullptr}); + // Aurora resolves duplicate paths later-entry-wins, so emit in registration order (SlotMap + // iteration is index order, and freed indices are reused). + std::vector slots; + s_runtimeOverlays.for_each([&](uint64_t, const auto& entry) { + if (entry.owner == &mod) { + slots.push_back(&entry.value); } - claim_overlay_path(claims, entry.discPath, mod); - files.emplace_back(strdup(entry.discPath.c_str()), reinterpret_cast(id), entry.size); + }); + std::ranges::sort(slots, {}, &RuntimeOverlaySlot::order); + + for (const auto* slot : slots) { + const auto id = s_nextOverlayId++; + if (slot->buffer != nullptr) { + s_overlayFiles.emplace(id, OverlayFileData{{}, nullptr, slot->buffer}); + } else { + s_overlayFiles.emplace(id, OverlayFileData{slot->bundlePath, mod.bundle, nullptr}); + } + claim_overlay_path(claims, slot->discPath, mod); + files.emplace_back(strdup(slot->discPath.c_str()), reinterpret_cast(id), slot->size); } } @@ -201,47 +208,39 @@ void overlay_sync_files() { uint64_t overlay_add_file( LoadedMod& mod, std::string discPath, std::string bundlePath, size_t size) { - const auto handle = s_nextRuntimeHandle++; - s_runtimeOverlays[&mod].push_back({ - .handle = handle, - .discPath = std::move(discPath), - .bundlePath = std::move(bundlePath), - .size = size, - }); + const auto handle = s_runtimeOverlays.emplace(mod, RuntimeOverlaySlot{ + .discPath = std::move(discPath), + .bundlePath = std::move(bundlePath), + .size = size, + .order = s_nextRuntimeOrder++, + }); s_overlaysDirty = true; return handle; } uint64_t overlay_add_buffer(LoadedMod& mod, std::string discPath, std::vector data) { - const auto handle = s_nextRuntimeHandle++; const auto size = data.size(); - s_runtimeOverlays[&mod].push_back({ - .handle = handle, - .discPath = std::move(discPath), - .buffer = std::make_shared>(std::move(data)), - .size = size, - }); + const auto handle = s_runtimeOverlays.emplace(mod, + RuntimeOverlaySlot{ + .discPath = std::move(discPath), + .buffer = std::make_shared>(std::move(data)), + .size = size, + .order = s_nextRuntimeOrder++, + }); s_overlaysDirty = true; return handle; } bool overlay_remove(LoadedMod& mod, uint64_t handle) { - const auto it = s_runtimeOverlays.find(&mod); - if (it == s_runtimeOverlays.end()) { + if (!s_runtimeOverlays.erase_owned(handle, mod)) { return false; } - if (std::erase_if(it->second, [&](const auto& entry) { return entry.handle == handle; }) == 0) { - return false; - } - if (it->second.empty()) { - s_runtimeOverlays.erase(it); - } s_overlaysDirty = true; return true; } void overlay_remove_mod(LoadedMod& mod) { - if (s_runtimeOverlays.erase(&mod) != 0) { + if (s_runtimeOverlays.erase_all(mod) != 0) { s_overlaysDirty = true; } } diff --git a/src/dusk/mods/svc/slot_map.hpp b/src/dusk/mods/svc/slot_map.hpp new file mode 100644 index 0000000000..3e45635d79 --- /dev/null +++ b/src/dusk/mods/svc/slot_map.hpp @@ -0,0 +1,188 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace dusk::mods { + +struct LoadedMod; + +namespace svc { + +template +class SlotMap { +public: + static_assert(std::is_nothrow_move_constructible_v); + + using Handle = uint64_t; + static constexpr Handle InvalidHandle = 0; + + struct Entry { + LoadedMod* owner = nullptr; + T value; + }; + + template + Handle emplace(LoadedMod& owner, Args&&... args) { + T value{std::forward(args)...}; + const auto index = allocate_index(); + auto& slot = m_slots[index]; + slot.entry.emplace(Entry{.owner = &owner, .value = std::move(value)}); + return make_handle(index, slot.generation); + } + + // Returned pointers remain valid only until the next mutating operation. + Entry* find(Handle handle) { + auto* slot = find_slot(handle); + return slot != nullptr ? &*slot->entry : nullptr; + } + + const Entry* find(Handle handle) const { + const auto* slot = find_slot(handle); + return slot != nullptr ? &*slot->entry : nullptr; + } + + Entry* find_owned(Handle handle, const LoadedMod& owner) { + auto* entry = find(handle); + return entry != nullptr && entry->owner == &owner ? entry : nullptr; + } + + const Entry* find_owned(Handle handle, const LoadedMod& owner) const { + const auto* entry = find(handle); + return entry != nullptr && entry->owner == &owner ? entry : nullptr; + } + + std::optional take(Handle handle) { + const auto index = handle_index(handle); + auto* slot = find_slot(handle); + if (slot == nullptr) { + return std::nullopt; + } + std::optional entry{std::move(slot->entry)}; + release_slot(index); + return entry; + } + + std::optional take_owned(Handle handle, const LoadedMod& owner) { + if (find_owned(handle, owner) == nullptr) { + return std::nullopt; + } + return take(handle); + } + + std::vector take_all(const LoadedMod& owner) { + std::vector entries; + for (size_t slotIndex = 0; slotIndex < m_slots.size(); ++slotIndex) { + const auto index = static_cast(slotIndex); + auto& slot = m_slots[index]; + if (!slot.entry.has_value() || slot.entry->owner != &owner) { + continue; + } + entries.push_back(std::move(*slot.entry)); + release_slot(index); + } + return entries; + } + + bool erase(Handle handle) { + const auto index = handle_index(handle); + if (find_slot(handle) == nullptr) { + return false; + } + release_slot(index); + return true; + } + + bool erase_owned(Handle handle, const LoadedMod& owner) { + if (find_owned(handle, owner) == nullptr) { + return false; + } + return erase(handle); + } + + size_t erase_all(const LoadedMod& owner) { + return take_all(owner).size(); + } + + template + void for_each(Fn&& fn) const { + // The visitor may inspect entries but must not mutate this SlotMap. + for (size_t slotIndex = 0; slotIndex < m_slots.size(); ++slotIndex) { + const auto index = static_cast(slotIndex); + const auto& slot = m_slots[index]; + if (slot.entry.has_value()) { + fn(make_handle(index, slot.generation), *slot.entry); + } + } + } + +private: + struct Slot { + uint32_t generation = 1; + std::optional entry; + }; + + static constexpr Handle make_handle(uint32_t index, uint32_t generation) { + return static_cast(generation) << 32 | index; + } + + static constexpr uint32_t handle_index(Handle handle) { + return static_cast(handle & std::numeric_limits::max()); + } + + static constexpr uint32_t handle_generation(Handle handle) { + return static_cast(handle >> 32); + } + + Slot* find_slot(Handle handle) { + return const_cast(std::as_const(*this).find_slot(handle)); + } + + const Slot* find_slot(Handle handle) const { + const auto index = handle_index(handle); + if (handle == InvalidHandle || index >= m_slots.size()) { + return nullptr; + } + const auto& slot = m_slots[index]; + if (!slot.entry.has_value() || slot.generation != handle_generation(handle)) { + return nullptr; + } + return &slot; + } + + uint32_t allocate_index() { + if (!m_freeSlots.empty()) { + const auto index = m_freeSlots.back(); + m_freeSlots.pop_back(); + return index; + } + if (m_slots.size() > std::numeric_limits::max()) { + throw std::length_error{"SlotMap handle space exhausted"}; + } + const auto index = static_cast(m_slots.size()); + m_slots.emplace_back(); + return index; + } + + void release_slot(uint32_t index) { + auto& slot = m_slots[index]; + slot.entry.reset(); + if (slot.generation == std::numeric_limits::max()) { + return; + } + ++slot.generation; + m_freeSlots.push_back(index); + } + + std::vector m_slots; + std::vector m_freeSlots; +}; + +} // namespace svc +} // namespace dusk::mods diff --git a/src/dusk/mods/svc/ui.cpp b/src/dusk/mods/svc/ui.cpp index 72366aaab7..cf4ea86808 100644 --- a/src/dusk/mods/svc/ui.cpp +++ b/src/dusk/mods/svc/ui.cpp @@ -2,6 +2,7 @@ #include "config.hpp" #include "registry.hpp" +#include "slot_map.hpp" #include "aurora/lib/logging.hpp" #include "dusk/mod_loader.hpp" @@ -33,7 +34,6 @@ namespace { aurora::Module Log("dusk::mods::ui"); enum class UiSlotKind : u8 { - Free, Window, Dialog, Pane, @@ -63,17 +63,14 @@ const char* slot_kind_name(UiSlotKind kind) { case UiSlotKind::MenuTab: return "menu tab"; default: - return "free"; + return "unknown"; } } -// Generation-checked handle slots. Game thread only: all mutations happen in service calls made -// from mod code, in UI callbacks (ui::update), or in the loader's deactivate paths. +// Game thread only: all mutations happen in service calls made from mod code, in UI callbacks +// (ui::update), or in the loader's deactivate paths. struct UiSlot { - // Bumped on free, which invalidates every outstanding handle to this slot. - uint32_t generation = 1; - UiSlotKind kind = UiSlotKind::Free; - LoadedMod* owner = nullptr; + UiSlotKind kind = UiSlotKind::Window; // Pane/Text/Progress/Control: freed automatically when the element is destroyed Rml::Element* element = nullptr; // Pane payload @@ -93,8 +90,7 @@ struct UiSlot { bool hasElementValue = false; }; -std::vector s_slots; -std::vector s_freeSlots; +SlotMap s_slots; struct ModUiPanel { UiPanelBuildFn build = nullptr; @@ -112,71 +108,26 @@ struct ModMenuTab { std::unordered_map> s_modMenuTabs; bool s_menuTabsDirty = false; -uint64_t handle_for(uint32_t index) { - return (uint64_t{s_slots[index].generation} << 32) | index; -} - -uint32_t slot_index(const UiSlot& slot) { - return static_cast(&slot - s_slots.data()); -} - UiSlot* slot_from_handle(uint64_t handle) { - const auto index = static_cast(handle & 0xFFFFFFFFu); - const auto generation = static_cast(handle >> 32); - if (index >= s_slots.size()) { - return nullptr; - } - auto& slot = s_slots[index]; - if (slot.kind == UiSlotKind::Free || slot.generation != generation) { - return nullptr; - } - return &slot; + auto* entry = s_slots.find(handle); + return entry != nullptr ? &entry->value : nullptr; } // Note: s_slots may reallocate on any later allocation, so callers must not hold the returned // slot reference across calls that can allocate (e.g. mod build callbacks); re-resolve instead. UiSlot& alloc_slot(LoadedMod& mod, UiSlotKind kind, uint64_t& outHandle) { - uint32_t index; - if (!s_freeSlots.empty()) { - index = s_freeSlots.back(); - s_freeSlots.pop_back(); - } else { - index = static_cast(s_slots.size()); - s_slots.emplace_back(); - } - auto& slot = s_slots[index]; - slot.kind = kind; - slot.owner = &mod; - outHandle = handle_for(index); - return slot; -} - -void free_slot(UiSlot& slot) { - slot.generation++; - slot.kind = UiSlotKind::Free; - slot.owner = nullptr; - slot.element = nullptr; - slot.pane = nullptr; - slot.helpPane = nullptr; - slot.document = nullptr; - slot.onClosed = nullptr; - slot.onClosedUserData = nullptr; - slot.styleScope = ui::DocumentScope::None; - slot.styleId.clear(); - slot.elementRml.clear(); - slot.elementFloat = 0.0f; - slot.hasElementValue = false; - s_freeSlots.push_back(slot_index(slot)); + outHandle = s_slots.emplace(mod, UiSlot{.kind = kind}); + return s_slots.find(outHandle)->value; } UiSlot* resolve(LoadedMod& mod, uint64_t handle, UiSlotKind kind, const char* what) { - auto* slot = slot_from_handle(handle); - if (slot == nullptr || slot->owner != &mod || slot->kind != kind) { + auto* entry = s_slots.find_owned(handle, mod); + if (entry == nullptr || entry->value.kind != kind) { Log.error("[{}] {}: stale or invalid {} handle {:#x}", mod.metadata.id, what, slot_kind_name(kind), handle); return nullptr; } - return slot; + return &entry->value; } // Whether the registration a callback was created under is still live. Callbacks captured by @@ -184,7 +135,7 @@ UiSlot* resolve(LoadedMod& mod, uint64_t handle, UiSlotKind kind, const char* wh // once a reload completes, but captured fn pointers still target the unloaded image. Teardown // frees the slots (ui_remove_mod), which invalidates every callback built under them. bool slot_live(uint64_t handle) { - return slot_from_handle(handle) != nullptr; + return s_slots.find(handle) != nullptr; } bool dialog_open(uint64_t handle) { @@ -197,30 +148,22 @@ bool dialog_open(uint64_t handle) { // The generation check makes a late detach of an already-recycled slot a no-op. class SlotDetachListener final : public Rml::EventListener { public: - SlotDetachListener(uint32_t index, uint32_t generation) - : m_index{index}, m_generation{generation} {} + explicit SlotDetachListener(uint64_t handle) : m_handle{handle} {} void ProcessEvent(Rml::Event&) override {} void OnDetach(Rml::Element*) override { - if (m_index < s_slots.size()) { - auto& slot = s_slots[m_index]; - if (slot.kind != UiSlotKind::Free && slot.generation == m_generation) { - free_slot(slot); - } - } + s_slots.erase(m_handle); delete this; } private: - uint32_t m_index; - uint32_t m_generation; + uint64_t m_handle; }; -void track_element(UiSlot& slot, Rml::Element& element) { +void track_element(uint64_t handle, UiSlot& slot, Rml::Element& element) { slot.element = &element; - element.AddEventListener( - Rml::EventId::Click, new SlotDetachListener(slot_index(slot), slot.generation)); + element.AddEventListener(Rml::EventId::Click, new SlotDetachListener{handle}); } template @@ -269,7 +212,7 @@ uint64_t wrap_pane(LoadedMod& mod, ui::Pane& pane, ui::Pane* helpPane) { auto& slot = alloc_slot(mod, UiSlotKind::Pane, handle); slot.pane = &pane; slot.helpPane = helpPane; - track_element(slot, *pane.root()); + track_element(handle, slot, *pane.root()); return handle; } @@ -446,14 +389,14 @@ bool wire_config_var_binding(LoadedMod& mod, const UiControlDesc& desc, ui::ModC } void on_mod_window_destroyed(uint64_t handle) { - auto* slot = slot_from_handle(handle); - if (slot == nullptr || slot->kind != UiSlotKind::Window) { + const auto* entry = s_slots.find(handle); + if (entry == nullptr || entry->value.kind != UiSlotKind::Window) { return; } - LoadedMod* mod = slot->owner; - const UiWindowClosedFn onClosed = slot->onClosed; - void* userData = slot->onClosedUserData; - free_slot(*slot); + auto released = s_slots.take(handle); + auto* mod = released->owner; + const UiWindowClosedFn onClosed = released->value.onClosed; + void* userData = released->value.onClosedUserData; if (mod != nullptr && onClosed != nullptr) { guarded_call(*mod, "window on_closed callback", [&] { onClosed(mod->context.get(), handle, userData); @@ -464,7 +407,7 @@ void on_mod_window_destroyed(uint64_t handle) { void on_mod_dialog_destroyed(uint64_t handle) { auto* slot = slot_from_handle(handle); if (slot != nullptr && slot->kind == UiSlotKind::Dialog) { - free_slot(*slot); + s_slots.erase(handle); } } @@ -573,7 +516,7 @@ ModResult ui_pane_add_text(LoadedMod& mod, uint64_t pane, const char* text, uint auto& elemSlot = alloc_slot(mod, UiSlotKind::Text, *outElem); elemSlot.elementRml = ui::escape(text); elemSlot.hasElementValue = true; - track_element(elemSlot, *elem); + track_element(*outElem, elemSlot, *elem); } return MOD_OK; } @@ -588,7 +531,7 @@ ModResult ui_pane_add_rml(LoadedMod& mod, uint64_t pane, const char* rml, uint64 auto& elemSlot = alloc_slot(mod, UiSlotKind::Text, *outElem); elemSlot.elementRml = rml; elemSlot.hasElementValue = true; - track_element(elemSlot, *elem); + track_element(*outElem, elemSlot, *elem); } return MOD_OK; } @@ -604,7 +547,7 @@ ModResult ui_pane_add_progress(LoadedMod& mod, uint64_t pane, float value, uint6 auto& elemSlot = alloc_slot(mod, UiSlotKind::Progress, *outElem); elemSlot.elementFloat = value; elemSlot.hasElementValue = true; - track_element(elemSlot, *elem); + track_element(*outElem, elemSlot, *elem); } return MOD_OK; } @@ -691,7 +634,7 @@ ModResult ui_pane_add_control( } if (outElem != nullptr) { auto& elemSlot = alloc_slot(mod, UiSlotKind::Control, *outElem); - track_element(elemSlot, *control->root()); + track_element(*outElem, elemSlot, *control->root()); } return MOD_OK; } @@ -740,13 +683,13 @@ ModResult ui_elem_set_progress(LoadedMod& mod, uint64_t elem, float value) { } ModResult ui_elem_set_class(LoadedMod& mod, uint64_t elem, const char* name, bool active) { - auto* slot = slot_from_handle(elem); - if (slot == nullptr || slot->owner != &mod || slot->element == nullptr) { + auto* entry = s_slots.find_owned(elem, mod); + if (entry == nullptr || entry->value.element == nullptr) { Log.error( "[{}] elem_set_class: stale or invalid element handle {:#x}", mod.metadata.id, elem); return MOD_INVALID_ARGUMENT; } - slot->element->SetClass(name, active); + entry->value.element->SetClass(name, active); return MOD_OK; } @@ -945,7 +888,7 @@ ModResult ui_unregister_menu_tab(LoadedMod& mod, uint64_t handle) { s_modMenuTabs.erase(it); } } - free_slot(*slot); + s_slots.erase_owned(handle, mod); s_menuTabsDirty = true; return MOD_OK; } @@ -1026,7 +969,7 @@ ModResult ui_register_styles( slot.styleId = fmt::format("{}:{:x}", mod.metadata.id, handle); if (!ui::register_scoped_styles(docScope, slot.styleId, rcss)) { Log.error("[{}] register_styles: failed to parse RCSS", mod.metadata.id); - free_slot(slot); + s_slots.erase(handle); return MOD_INVALID_ARGUMENT; } outHandle = handle; @@ -1056,8 +999,8 @@ ModResult ui_unregister_styles(LoadedMod& mod, uint64_t handle) { if (slot == nullptr) { return MOD_INVALID_ARGUMENT; } - ui::unregister_scoped_styles(slot->styleScope, slot->styleId); - free_slot(*slot); + auto released = s_slots.take_owned(handle, mod); + ui::unregister_scoped_styles(released->value.styleScope, released->value.styleId); return MOD_OK; } @@ -1066,14 +1009,12 @@ void ui_remove_mod(LoadedMod& mod) { if (s_modMenuTabs.erase(&mod) != 0) { s_menuTabsDirty = true; } - for (auto& slot : s_slots) { - if (slot.kind == UiSlotKind::Free || slot.owner != &mod) { - continue; - } + auto entries = s_slots.take_all(mod); + for (auto& entry : entries) { + auto& slot = entry.value; switch (slot.kind) { case UiSlotKind::Window: { auto* window = static_cast(slot.document); - free_slot(slot); if (window != nullptr) { window->force_close(); } @@ -1081,7 +1022,6 @@ void ui_remove_mod(LoadedMod& mod) { } case UiSlotKind::Dialog: { auto* dialog = static_cast(slot.document); - free_slot(slot); if (dialog != nullptr) { dialog->force_close(); } @@ -1089,10 +1029,8 @@ void ui_remove_mod(LoadedMod& mod) { } case UiSlotKind::Style: ui::unregister_scoped_styles(slot.styleScope, slot.styleId); - free_slot(slot); break; default: - free_slot(slot); break; } } diff --git a/src/dusk/ui/logs_window.cpp b/src/dusk/ui/logs_window.cpp index fc9dad96ca..0a6fc2402d 100644 --- a/src/dusk/ui/logs_window.cpp +++ b/src/dusk/ui/logs_window.cpp @@ -73,10 +73,10 @@ std::string format_time(int64_t timeMs) { } Rml::Element* append_span(Rml::Element* parent, const char* className, const Rml::String& text) { - auto span = parent->GetOwnerDocument()->CreateElement("span"); + auto* span = append(parent, "span"); span->SetClass(className, true); - append_text(span.get(), text); - return parent->AppendChild(std::move(span)); + append_text(span, text); + return span; } } // namespace @@ -260,18 +260,18 @@ Rml::Element* LogsWindow::append_log_line(const mods::log::Line& line) { modId = "?"; } - auto elem = mDocument->CreateElement("div"); + auto* elem = append(mLinesElem, "div"); elem->SetClass("log-line", true); elem->SetClass(level_class(line.level), true); constexpr const char* kNbsp = "\xc2\xa0"; - append_span(elem.get(), "log-time", format_time(line.timeMs)); - append_text(elem.get(), kNbsp); - append_span(elem.get(), "log-mod", fmt::format("[{}]", modId)); - append_text(elem.get(), kNbsp); - append_span(elem.get(), "log-msg", line.message); + append_span(elem, "log-time", format_time(line.timeMs)); + append_text(elem, kNbsp); + append_span(elem, "log-mod", fmt::format("[{}]", modId)); + append_text(elem, kNbsp); + append_span(elem, "log-msg", line.message); - return mLinesElem->AppendChild(std::move(elem)); + return elem; } void LogsWindow::copy_to_clipboard() {