From 7df07c6904dcfdb61fa1088255723e4617552bde Mon Sep 17 00:00:00 2001 From: Luke Street Date: Thu, 9 Jul 2026 18:54:44 -0600 Subject: [PATCH] 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