diff --git a/profiles/vcs/CMakeLists.txt b/profiles/vcs/CMakeLists.txt index 0b03b1d..4800b7d 100644 --- a/profiles/vcs/CMakeLists.txt +++ b/profiles/vcs/CMakeLists.txt @@ -222,6 +222,8 @@ endif() add_custom_command(TARGET VCSNative POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different "${VCS_PROFILE_DIR}/config/VCSNative.ini" "$/VCSNative.ini" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${VCS_PROFILE_DIR}/config/ProperShaders.ini" "$/ProperShaders.ini" COMMAND ${CMAKE_COMMAND} -E copy_if_different "${VCS_PROFILE_DIR}/data/VCSProject2DFX_Lights.bin" "$/VCSProject2DFX_Lights.bin" @@ -230,7 +232,10 @@ add_custom_command(TARGET VCSNative POST_BUILD "$/PROJECT2DFX_ATTRIBUTION.md" COMMAND ${CMAKE_COMMAND} -E copy_if_different "${VCS_PROFILE_DIR}/third_party/project2dfx/LICENSE.txt" - "$/PROJECT2DFX_LICENSE.txt") + "$/PROJECT2DFX_LICENSE.txt" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${VCS_PROFILE_DIR}/third_party/cloudworks/ATTRIBUTION.md" + "$/CLOUDWORKS_ATTRIBUTION.md") # Profile-specific generator. The root psp_recomp target stays game-neutral. add_executable(vcs_recomp tools/vcs_codegen_main.cpp) diff --git a/profiles/vcs/THIRD_PARTY.md b/profiles/vcs/THIRD_PARTY.md index 357dcc6..30c1db2 100644 --- a/profiles/vcs/THIRD_PARTY.md +++ b/profiles/vcs/THIRD_PARTY.md @@ -6,5 +6,7 @@ The VCS profile includes components that are distributed under licenses separate - SMAA shader resources: see `third_party/smaa/LICENSE.txt`. - Project2DFX-derived VCS LOD-light data/behavior reference by ThirteenAG: MIT. See `third_party/project2dfx/LICENSE.txt` and `third_party/project2dfx/ATTRIBUTION.md`. - HDR shader material under `shaders/hdr`: see `shaders/hdr/LICENSE_SIMULATEHDR.txt`. +- CloudWorks Alpha 4.0 volumetric-cloud density/noise model by Brian Tu (RTU): + CC BY-NC-SA 3.0. See `third_party/cloudworks/ATTRIBUTION.md`. Commercial GTA assets and executables are not part of the repository. diff --git a/profiles/vcs/config/ProperShaders.ini b/profiles/vcs/config/ProperShaders.ini new file mode 100644 index 0000000..b4cc371 --- /dev/null +++ b/profiles/vcs/config/ProperShaders.ini @@ -0,0 +1,10 @@ +; Standalone CloudWorks sky for VCSNative. This does not use the game's +; timecycle or weather. Set Enabled=false to restore the unmodified game sky. +[VolumetricClouds] +Enabled=true +MarchSteps=24 +Coverage=0.72 +Opacity=0.84 +; Keep the density field completely static while validating camera anchoring. +; A small positive value can be restored after the camera test is confirmed. +Speed=0.0 diff --git a/profiles/vcs/host/ge_cloud_camera_math.hpp b/profiles/vcs/host/ge_cloud_camera_math.hpp new file mode 100644 index 0000000..3b304bc --- /dev/null +++ b/profiles/vcs/host/ge_cloud_camera_math.hpp @@ -0,0 +1,167 @@ +#pragma once + +#include +#include +#include + +namespace vcs { + +// GE affine matrices use the same layout as ge_renderer::transform_4x3: +// +// view = A * world + t +// A = [m0 m3 m6; m1 m4 m7; m2 m5 m8], t = [m9 m10 m11]. +// +// view_to_world is A^-1 in row-major order. The three named axes are its +// columns, so A^-1 * view_direction is exactly +// right*x + up*y + forward*z. They are intentionally not normalized: retaining +// scale/shear makes the result a true inverse for every nonsingular GE view. +struct GeCloudCameraFrame { + std::array view_to_world{}; + std::array position{}; + std::array right{}; + std::array up{}; + std::array forward{}; +}; + +namespace ge_cloud_camera_detail { + +constexpr double absolute(double value) noexcept { + return value < 0.0 ? -value : value; +} + +constexpr bool finite(double value) noexcept { + return value == value && + value <= std::numeric_limits::max() && + value >= -std::numeric_limits::max(); +} + +constexpr bool finite_float_result(double value) noexcept { + return finite(value) && + absolute(value) <= static_cast(std::numeric_limits::max()); +} + +} // namespace ge_cloud_camera_detail + +// Computes a full inverse of the GE view's 3x3 linear part. Returns false for +// non-finite or (numerically) singular input and leaves `out` unchanged. +[[nodiscard]] inline constexpr bool ge_cloud_invert_view_linear( + const std::array &view, + std::array &out) noexcept { + using ge_cloud_camera_detail::absolute; + using ge_cloud_camera_detail::finite; + using ge_cloud_camera_detail::finite_float_result; + + for (float value : view) { + if (!finite(static_cast(value))) return false; + } + + const double a = view[0], b = view[3], c = view[6]; + const double d = view[1], e = view[4], f = view[7]; + const double g = view[2], h = view[5], i = view[8]; + + double scale = 0.0; + for (std::size_t index = 0; index < 9; ++index) { + const double value = absolute(static_cast(view[index])); + if (value > scale) scale = value; + } + if (scale == 0.0) return false; + + const double determinant = + a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); + const double singular_threshold = + 8.0 * static_cast(std::numeric_limits::epsilon()) * + scale * scale * scale; + if (!finite(determinant) || absolute(determinant) <= singular_threshold) + return false; + + const double inverse_determinant = 1.0 / determinant; + const std::array inverse{ + (e * i - f * h) * inverse_determinant, + (c * h - b * i) * inverse_determinant, + (b * f - c * e) * inverse_determinant, + (f * g - d * i) * inverse_determinant, + (a * i - c * g) * inverse_determinant, + (c * d - a * f) * inverse_determinant, + (d * h - e * g) * inverse_determinant, + (b * g - a * h) * inverse_determinant, + (a * e - b * d) * inverse_determinant, + }; + + std::array result{}; + for (std::size_t index = 0; index < inverse.size(); ++index) { + if (!finite_float_result(inverse[index])) return false; + result[index] = static_cast(inverse[index]); + } + out = result; + return true; +} + +// Builds the world-space camera origin and the exact view-to-world basis from +// a GE world-to-view matrix. Returns false and leaves `out` unchanged if the +// matrix cannot be inverted or any derived value is non-finite. +[[nodiscard]] inline constexpr bool ge_cloud_camera_frame_from_view( + const std::array &view, + GeCloudCameraFrame &out) noexcept { + std::array inverse{}; + if (!ge_cloud_invert_view_linear(view, inverse)) return false; + + const double tx = view[9], ty = view[10], tz = view[11]; + const std::array position{ + -(static_cast(inverse[0]) * tx + + static_cast(inverse[1]) * ty + + static_cast(inverse[2]) * tz), + -(static_cast(inverse[3]) * tx + + static_cast(inverse[4]) * ty + + static_cast(inverse[5]) * tz), + -(static_cast(inverse[6]) * tx + + static_cast(inverse[7]) * ty + + static_cast(inverse[8]) * tz), + }; + for (double value : position) { + if (!ge_cloud_camera_detail::finite_float_result(value)) return false; + } + + GeCloudCameraFrame result{}; + result.view_to_world = inverse; + result.position = {static_cast(position[0]), + static_cast(position[1]), + static_cast(position[2])}; + result.right = {inverse[0], inverse[3], inverse[6]}; + result.up = {inverse[1], inverse[4], inverse[7]}; + result.forward = {inverse[2], inverse[5], inverse[8]}; + out = result; + return true; +} + +namespace ge_cloud_camera_detail { + +// Compile-time contract check using an exact 90-degree rotation and +// translation. This also catches accidental row/column transposition. +static_assert([] { + constexpr std::array view{ + 0.0f, 1.0f, 0.0f, + -1.0f, 0.0f, 0.0f, + 0.0f, 0.0f, 1.0f, + 3.0f, -2.0f, -4.0f, + }; + GeCloudCameraFrame frame{}; + return ge_cloud_camera_frame_from_view(view, frame) && + frame.position == std::array{2.0f, 3.0f, 4.0f} && + frame.right == std::array{0.0f, -1.0f, 0.0f} && + frame.up == std::array{1.0f, 0.0f, 0.0f} && + frame.forward == std::array{0.0f, 0.0f, 1.0f}; +}()); + +static_assert([] { + constexpr std::array singular{ + 1.0f, 0.0f, 0.0f, + 1.0f, 0.0f, 0.0f, + 0.0f, 0.0f, 1.0f, + 0.0f, 0.0f, 0.0f, + }; + GeCloudCameraFrame frame{}; + return !ge_cloud_camera_frame_from_view(singular, frame); +}()); + +} // namespace ge_cloud_camera_detail +} // namespace vcs diff --git a/profiles/vcs/host/ge_cloudworks_present_shader.hpp b/profiles/vcs/host/ge_cloudworks_present_shader.hpp new file mode 100644 index 0000000..8a78196 --- /dev/null +++ b/profiles/vcs/host/ge_cloudworks_present_shader.hpp @@ -0,0 +1,324 @@ +#pragma once + +namespace vcs { + +// Standalone, single-present-pass adaptation of the low CloudWorks Alpha 4.0 +// cloud profile by Brian Tu (RTU). CloudWorks is licensed CC BY-NC-SA 3.0. +// +// Root-constant contract (b1, 21 DWORDs total): +// 0..3 unused draw-state padding +// 4 unused draw-state padding +// 5..7 CloudRayRight.xyz (NDC-x world-ray coefficient) +// 8 CloudTime (seconds * configured speed) +// 9..11 CloudRayUp.xyz (NDC-y world-ray coefficient) +// 12 CloudCoverage [0, 1] +// 13..15 CloudRayForward.xyz (world ray at NDC 0,0) +// 16 CloudOpacity [0, 1] +// 17..19 CloudCameraPosition.xyz (world space, Z up) +// 20 CloudSettings: bits 0..7 = march steps [4,64], bit 8 = enabled +// +// CloudRayRight/Up are deliberately not normalized camera axes. The host must +// bake inverse projection into them. CloudRayForward must come directly from +// unprojection (never cross(right, up)); this keeps the noise field fixed in +// world space even for a reflected/scaled GE view basis. +inline constexpr char kCloudWorksPresentShaderHlsl[] = R"CLOUD_HLSL( +Texture2D SourceTexture : register(t0); +SamplerState SourceSampler : register(s0); + +cbuffer CloudPresentState : register(b1) { + uint4 UnusedDrawState0; + uint UnusedDrawState1; + float3 CloudRayRight; + float CloudTime; + float3 CloudRayUp; + float CloudCoverage; + float3 CloudRayForward; + float CloudOpacity; + float3 CloudCameraPosition; + uint CloudSettings; +}; + +struct PresentVertexOutput { + float4 position : SV_POSITION; + float2 uv : TEXCOORD0; +}; + +PresentVertexOutput PresentVS(uint id : SV_VertexID) { + PresentVertexOutput output; + if (id == 0u) { + output.position = float4(-1.0, -1.0, 0.0, 1.0); + output.uv = float2(0.0, 1.0); + } else if (id == 1u) { + output.position = float4(-1.0, 3.0, 0.0, 1.0); + output.uv = float2(0.0, -1.0); + } else { + output.position = float4(3.0, -1.0, 0.0, 1.0); + output.uv = float2(2.0, 1.0); + } + return output; +} + +// CloudWorks' original scalar sine hash and linearly interpolated value noise. +float CwHash(float value) { + return frac(sin(value / 1873.1873) * 1618.03398875); +} + +float CwNoise2(float3 p) { + float3 cell = floor(p); + float3 f = frac(p); + float n = 1153.0 * cell.x + 2381.0 * cell.y + p.z; + float right = n + 1153.0; + float down = n + 2381.0; + float opposite = right + 2381.0; + return lerp(lerp(CwHash(n), CwHash(right), f.x), + lerp(CwHash(down), CwHash(opposite), f.x), f.y); +} + +float CwNoise3(float3 p) { + float3 cell = floor(p); + float3 f = frac(p); + float n = 1153.0 * cell.x + 2381.0 * cell.y + cell.z; + float right = n + 1153.0; + float down = n + 2381.0; + float opposite = right + 2381.0; + float a = lerp(CwHash(n), CwHash(n + 1.0), f.z); + float b = lerp(CwHash(right), CwHash(right + 1.0), f.z); + float c = lerp(CwHash(down), CwHash(down + 1.0), f.z); + float d = lerp(CwHash(opposite), CwHash(opposite + 1.0), f.z); + return lerp(lerp(a, b, f.x), lerp(c, d, f.x), f.y); +} + +float CwSmooth(float edge0, float edge1, float value) { + float x = saturate((value - edge0) / (edge1 - edge0)); + return x * x * (3.0 - 2.0 * x); +} + +// x = shape factor, y = high density threshold, z = low threshold. +float3 CwCloudShape(float height, float coverage) { + const float bottom = 300.0; + const float middle = 450.0; + const float top = 700.0; + float body = CwSmooth(0.0, middle, height) * + (1.0 - CwSmooth(middle, top, height)); + // The handheld sky is a very small target after upscale. Use the same + // CloudWorks profile but broaden its occupied threshold range so the deck + // reads as a cloud mass rather than a few isolated wisps. + float bottomRange = 0.24 + coverage * 0.52; + float soft = (height - top) / (middle - top) * + (bottomRange - 0.1) + 0.1; + float total = rcp(0.92 + coverage * 0.48); + return float3(body, total + soft, total - soft); +} + +float3 CwOffsetA(float flow) { return float3(-1.8, 1.0, 0.0) * flow; } +float3 CwOffsetB(float flow) { return float3(-2.0, -0.2, 0.0) * flow; } +float3 CwOffsetC(float flow) { return float3(-3.0, 0.0, -0.5) * flow; } +float3 CwOffsetD(float flow) { return float3(-3.5, 0.0, 0.1) * flow; } + +float CwChunk(float3 worldPosition, float shapeFactor, float flow) { + float3 p = worldPosition; + p += float3(-0.5, 0.0, 0.0) * p.z; + float largeNoise = CwNoise3((p + CwOffsetA(flow)) * 0.0008); + float smallNoise = CwNoise3((p + CwOffsetB(flow)) * 0.005); + return largeNoise * (smallNoise * 0.5 + 0.3) * shapeFactor; +} + +float3 CwDistortion(float lump) { + return float3(cos(lump * 1.6) * 60.0, 0.0, -lump * 8.0); +} + +float CwDetail(float lump, float3 worldPosition, float flow) { + float3 distortion = CwDistortion(lump); + float detail = 0.3 * CwNoise3( + (worldPosition + CwOffsetC(flow) + distortion) * 0.02); + distortion.z -= detail * 16.0; + float3 detailPosition = worldPosition + CwOffsetD(flow); + detail += 0.2 * CwNoise3((detailPosition + distortion / 3.0) * 0.04); + detail += detail * 0.6 * + CwNoise3((detailPosition + distortion * 8.0) * 0.1); + return detail; +} + +// x = raw density field, y = extinction density, z = low threshold. +float3 CwDensity(float3 worldPosition, float coverage, float flow) { + float3 shape = CwCloudShape(worldPosition.z, coverage); + float lump = CwChunk(worldPosition, shape.x, flow); + float field = lump * (1.0 + CwDetail(lump, worldPosition, flow)); + float solidness = lerp(0.0, 7.0 * coverage, + saturate((worldPosition.z - 300.0) / 400.0)); + float density = saturate((field - shape.z) / + max(shape.y - shape.z, 1.0e-4)) * solidness; + return float3(field, density * 1.45, shape.z); +} + +// One inexpensive approximation of CloudWorks' eight-sample sun shadow march. +// It retains the original Chunk + DetailA profile used by ShadowMarching. +float CwShadowDensity(float3 worldPosition, float coverage, float flow) { + float3 shape = CwCloudShape(worldPosition.z, coverage); + float lump = CwChunk(worldPosition, shape.x, flow); + float3 distortion = CwDistortion(lump); + float detail = 0.3 * CwNoise3( + (worldPosition + CwOffsetC(flow) + distortion) * 0.02) * 1.75; + float field = lump * (1.0 + detail); + float solidness = lerp(0.0, 5.0 * coverage, + saturate((worldPosition.z - 300.0) / 400.0)); + return saturate((field - (shape.z - 0.1)) / + max(shape.y - (shape.z - 0.1), 1.0e-4)) * solidness; +} + +float CwSkyMask(float2 uv, float3 source, float rayHeight) { + uint width = 1u; + uint height = 1u; + SourceTexture.GetDimensions(width, height); + float2 pixel = rcp(float2(max(width, 1u), max(height, 1u))); + float3 left = SourceTexture.SampleLevel(SourceSampler, + saturate(uv - float2(pixel.x, 0.0)), 0.0).rgb; + float3 right = SourceTexture.SampleLevel(SourceSampler, + saturate(uv + float2(pixel.x, 0.0)), 0.0).rgb; + float3 above = SourceTexture.SampleLevel(SourceSampler, + saturate(uv - float2(0.0, pixel.y)), 0.0).rgb; + float3 below = SourceTexture.SampleLevel(SourceSampler, + saturate(uv + float2(0.0, pixel.y)), 0.0).rgb; + float localEdge = max(max(length(source - left), length(source - right)), + max(length(source - above), length(source - below))); + float flatSky = 1.0 - CwSmooth(0.025, 0.12, localEdge); + float blueOverRed = CwSmooth(0.025, 0.16, source.b - source.r); + float blueOverGreen = CwSmooth(-0.04, 0.10, source.b - source.g); + float luminance = dot(source, float3(0.2126, 0.7152, 0.0722)); + float visibleSky = blueOverRed * blueOverGreen * + CwSmooth(0.12, 0.34, luminance); + float aboveHorizon = CwSmooth(0.035, 0.13, rayHeight); + return saturate(aboveHorizon * visibleSky * lerp(0.55, 1.0, flatSky)); +} + +// Returns premultiplied cloud radiance in rgb and remaining transmittance in a. +float4 CwMarchLowLayer(float3 rayOrigin, float3 rayDirection, + float coverage, uint marchSteps, float flow) { + float4 result = float4(0.0, 0.0, 0.0, 1.0); + const float cloudBottom = 300.0; + const float cloudTop = 700.0; + const float cloudFadeDistance = 6000.0; + float validDirection = rayDirection.z > 1.0e-4 ? 1.0 : 0.0; + float safeRayHeight = max(rayDirection.z, 1.0e-4); + float slabBegin = (cloudBottom - rayOrigin.z) / safeRayHeight; + float slabEnd = (cloudTop - rayOrigin.z) / safeRayHeight; + float rayBegin = max(min(slabBegin, slabEnd), 0.0); + float rayEnd = min(max(slabBegin, slabEnd), cloudFadeDistance); + rayEnd = validDirection > 0.5 ? rayEnd : rayBegin; + + // World-anchored jitter breaks coherent contours without producing a + // screen-space stipple pattern that turns with the camera. + float3 entryPosition = rayOrigin + rayDirection * rayBegin; + float nominalStep = (rayEnd - rayBegin) / max(float(marchSteps), 1.0); + float jitter = CwNoise3(entryPosition * float3(0.031, 0.031, 0.013) + 19.19); + float distanceAlongRay = rayBegin + jitter * min(nominalStep, 40.0); + + const float3 sunDirection = normalize(float3(0.38, -0.28, 0.88)); + const float3 baseColor = float3(0.27, 0.32, 0.40); + const float3 sunColor = float3(1.02, 1.00, 0.93); + float3 radiance = 0.0; + float transmittance = 1.0; + float previousField = 0.0; + float previousDensity = 0.0; + + [loop] + for (uint stepIndex = 0u; stepIndex < 64u; ++stepIndex) { + if (stepIndex >= marchSteps || distanceAlongRay >= rayEnd || + transmittance <= 0.02) break; + + float3 worldPosition = rayOrigin + rayDirection * distanceAlongRay; + float3 densitySample = CwDensity(worldPosition, coverage, flow); + + // CloudWorks' dynamic empty-space skipping: dense regions approach a + // five-unit step; empty regions approach 80 units, expanding with + // distance. Unlike the old uniform slab division, the sampled Z + // planes therefore cannot form screen-aligned slices. + float occupancy = saturate((2.0 * densitySample.x - previousField) / + max(densitySample.z * 0.85, 1.0e-4)); + float stepLength = lerp(80.0, 5.0, occupancy); + stepLength *= lerp(1.0, 8.0, + saturate(distanceAlongRay / cloudFadeDistance)); + stepLength += CwNoise2(worldPosition + float3(0.0, 0.0, flow)) * 5.0; + stepLength = min(stepLength, 160.0); + stepLength = min(stepLength, rayEnd - distanceAlongRay); + + if (densitySample.y > 1.0e-4 && stepLength > 0.0) { + // Trapezoidal Beer-Lambert integration makes opacity independent + // of the number of steps and avoids the saturated per-slice alpha + // produced by density * uniformStepLength. + float meanDensity = 0.5 * (previousDensity + densitySample.y); + float opticalDepth = meanDensity * min(stepLength, 80.0) * 0.018; + float segmentAlpha = 1.0 - exp(-opticalDepth); + + float shadowDensity = CwShadowDensity( + worldPosition + sunDirection * 60.0, coverage, flow); + float sunVisibility = exp(-shadowDensity * 1.35); + float forwardScatter = pow(saturate(dot(rayDirection, sunDirection)), 24.0); + float lighting = saturate(0.28 + sunVisibility * 0.72); + float3 cloudColor = lerp(baseColor, sunColor, lighting); + cloudColor += sunColor * forwardScatter * 0.10; + + radiance += transmittance * segmentAlpha * cloudColor; + transmittance *= 1.0 - segmentAlpha; + } + + previousField = densitySample.x; + previousDensity = densitySample.y; + distanceAlongRay += max(stepLength, 1.0); + } + + result = float4(radiance, saturate(transmittance)); + return result; +} + +float4 PresentPS(PresentVertexOutput input) : SV_TARGET { + float4 source = SourceTexture.SampleLevel(SourceSampler, input.uv, 0.0); + const uint enabledBit = 0x100u; + if ((CloudSettings & enabledBit) == 0u) return source; + + uint marchSteps = min(64u, max(4u, CloudSettings & 0xFFu)); + float2 ndc = float2(input.uv.x * 2.0 - 1.0, + 1.0 - input.uv.y * 2.0); + float3 rayDirection = normalize(CloudRayForward + + CloudRayRight * ndc.x + + CloudRayUp * ndc.y); + float skyMask = CwSkyMask(input.uv, source.rgb, rayDirection.z); + if (skyMask <= 1.0e-3) return source; + + // Preserve the existing INI Speed semantics while giving the low profile + // offsets a useful world-space velocity. + float flow = CloudTime * 25.0; + float4 clouds = CwMarchLowLayer(CloudCameraPosition, rayDirection, + saturate(CloudCoverage), marchSteps, flow); + float3 cloudComposite = clouds.rgb + source.rgb * clouds.a; + source.rgb = lerp(source.rgb, cloudComposite, + skyMask * saturate(CloudOpacity)); + return source; +} + +// World-target path. This is deliberately separate from PresentPS: its pixels +// are in the same coordinate system as the selected GE camera, and the D3D12 +// pipeline depth-tests against the world's untouched clear depth. The result is +// blended behind geometry before VCS scales/composites that target to display. +float4 CloudTargetPS(PresentVertexOutput input) : SV_TARGET { + const uint enabledBit = 0x100u; + if ((CloudSettings & enabledBit) == 0u) discard; + uint marchSteps = min(64u, max(4u, CloudSettings & 0xFFu)); + float2 ndc = float2(input.uv.x * 2.0 - 1.0, + 1.0 - input.uv.y * 2.0); + float3 rayDirection = normalize(CloudRayForward + + CloudRayRight * ndc.x + + CloudRayUp * ndc.y); + if (rayDirection.z <= 1.0e-4) discard; + float flow = CloudTime * 25.0; + float4 clouds = CwMarchLowLayer(CloudCameraPosition, rayDirection, + saturate(CloudCoverage), marchSteps, flow); + float alpha = saturate((1.0 - clouds.a) * CloudOpacity); + if (alpha <= 1.0e-4) discard; + // CwMarchLowLayer returns premultiplied radiance, so the matching PSO uses + // ONE / INV_SRC_ALPHA blending. + return float4(clouds.rgb * CloudOpacity, alpha); +} +)CLOUD_HLSL"; + +} // namespace vcs diff --git a/profiles/vcs/host/ge_gpu_backend.hpp b/profiles/vcs/host/ge_gpu_backend.hpp index 3e69046..6c654a7 100644 --- a/profiles/vcs/host/ge_gpu_backend.hpp +++ b/profiles/vcs/host/ge_gpu_backend.hpp @@ -413,6 +413,15 @@ void shutdown_ge_gpu_backend() noexcept; [[nodiscard]] bool ge_gpu_backend_graphics_ready() noexcept; void ge_gpu_backend_record_draw(const GeGpuDrawDescriptor &draw) noexcept; +// Captures the dominant projected world camera for optional native post effects. +// The matrices are observed with the draw, before the GE state advances. +void ge_gpu_backend_observe_camera(const std::array &view, + const std::array &projection, + const std::array &viewport, + const std::array &camera_position, + const GeGpuDrawDescriptor &draw, + std::uint32_t vertex_weight) noexcept; + // Stages already-decoded vertices into a persistently mapped Vulkan buffer. // This does not replace the software rasterizer yet; it proves that real VCS // geometry can cross the host->Vulkan boundary with no per-draw allocation. diff --git a/profiles/vcs/host/ge_gpu_backend_dx12.cpp b/profiles/vcs/host/ge_gpu_backend_dx12.cpp index dcadbf2..0c2670c 100644 --- a/profiles/vcs/host/ge_gpu_backend_dx12.cpp +++ b/profiles/vcs/host/ge_gpu_backend_dx12.cpp @@ -1,9 +1,12 @@ #include "ge_gpu_backend.hpp" +#include "ge_cloud_camera_math.hpp" +#include "ge_cloudworks_present_shader.hpp" #include "vcs_config.hpp" #include "vcs_runtime_log.hpp" #include #include +#include #include #include #include @@ -117,6 +120,27 @@ struct Dx12PixelConstants { }; static_assert(sizeof(Dx12PixelConstants) == 5u * sizeof(std::uint32_t)); +struct CloudCameraCandidate { + std::array view{}; + std::array projection{}; + // scale X/Y, center X/Y and offset X/Y from the GE viewport. Keeping this + // with the exact draw camera lets the present shader reconstruct the same + // rays that the native geometry path rasterized. + std::array viewport{}; + std::array camera_position{}; + std::uint32_t target{}; + std::uint64_t weight{}; + std::uint64_t occluding_weight{}; +}; + +struct CloudPresentConstants { + std::array ray_right_time{}; + std::array ray_up_coverage{}; + std::array ray_forward_opacity{}; + std::array camera_settings{}; +}; +static_assert(sizeof(CloudPresentConstants) == 16u * sizeof(std::uint32_t)); + struct Dx12FrameResources { ComPtr allocator; ComPtr upload_buffer; @@ -188,6 +212,7 @@ struct Dx12GeState { std::vector packed_0115_vertices; std::vector indices; std::vector batches; + std::vector cloud_cameras; std::vector frame_rgba; std::vector last_texture_rgba; @@ -249,8 +274,10 @@ struct Dx12GeState { std::uint32_t swap_width{}; std::uint32_t swap_height{}; ComPtr present_pipeline; + ComPtr cloud_target_pipeline; ComPtr present_vertex_shader; ComPtr present_pixel_shader; + ComPtr cloud_target_pixel_shader; bool direct_present_ok{}; std::uint32_t presented_framebuffer{}; std::uint32_t missed_display_intervals{}; @@ -813,6 +840,16 @@ cbuffer DrawPixelState : register(b1) { uint TextureEnvPacked; uint FogControlPacked; uint FramebufferFormat; + float3 CloudRight; + float CloudInvProjectionX; + float3 CloudUp; + float CloudInvProjectionY; + float3 CloudCameraPosition; + float CloudTime; + float CloudCoverage; + float CloudOpacity; + float CloudMarchSteps; + float CloudEnabled; }; struct VSIn { float4 position : POSITION; @@ -1022,19 +1059,7 @@ float4 PSMain(VSOut input) : SV_TARGET { return false; } - const char *present = R"HLSL( -Texture2D SourceTexture : register(t0); -SamplerState SourceSampler : register(s0); -struct VSOut { float4 position : SV_POSITION; float2 uv : TEXCOORD0; }; -VSOut PresentVS(uint id : SV_VertexID) { - VSOut o; - if (id == 0u) { o.position=float4(-1.0,-1.0,0.0,1.0); o.uv=float2(0.0,1.0); } - else if (id == 1u) { o.position=float4(-1.0,3.0,0.0,1.0); o.uv=float2(0.0,-1.0); } - else { o.position=float4(3.0,-1.0,0.0,1.0); o.uv=float2(2.0,1.0); } - return o; -} -float4 PresentPS(VSOut input) : SV_TARGET { return SourceTexture.Sample(SourceSampler, input.uv); } -)HLSL"; + const char *present = kCloudWorksPresentShaderHlsl; errors.Reset(); hr = D3DCompile(present, std::strlen(present), "VCSNativeDX12GEPresent", nullptr, nullptr, "PresentVS", "vs_5_1", flags, 0u, &s.present_vertex_shader, &errors); @@ -1051,6 +1076,16 @@ float4 PresentPS(VSOut input) : SV_TARGET { return SourceTexture.Sample(SourceSa : hr_text(hr, "D3DCompile(DX12 GE Present PS)"); return false; } + errors.Reset(); + hr = D3DCompile(present, std::strlen(present), "VCSNativeDX12GECloudTarget", + nullptr, nullptr, "CloudTargetPS", "ps_5_1", flags, 0u, + &s.cloud_target_pixel_shader, &errors); + if (FAILED(hr)) { + error = errors ? std::string(static_cast(errors->GetBufferPointer()), + errors->GetBufferSize()) + : hr_text(hr, "D3DCompile(DX12 GE cloud target PS)"); + return false; + } return true; } @@ -1087,7 +1122,10 @@ bool create_root_signature(Dx12GeState &s, std::string &error) noexcept { parameters[3].ParameterType = D3D12_ROOT_PARAMETER_TYPE_32BIT_CONSTANTS; parameters[3].Constants.ShaderRegister = 1u; parameters[3].Constants.RegisterSpace = 0u; - parameters[3].Constants.Num32BitValues = 5u; + // 5 draw-state DWORDs plus 16 cloud-present DWORDs. Together with the + // 40-DWORD vertex transform and two descriptor tables this uses 63 of the + // D3D12 root signature's 64 DWORD budget. + parameters[3].Constants.Num32BitValues = 21u; parameters[3].ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL; D3D12_ROOT_SIGNATURE_DESC desc{}; desc.NumParameters = static_cast(parameters.size()); @@ -1664,6 +1702,254 @@ std::uint32_t present_sampler(Dx12GeState &s) noexcept { return ensure_sampler(s, draw); } +bool invert_cloud_matrix(const std::array &matrix, + std::array &inverse) noexcept { + // Gauss-Jordan in double precision. GE projection matrices are small, but + // the far plane can still make a float-only inverse needlessly fragile. + double rows[4][8]{}; + double scale = 0.0; + for (std::size_t row = 0u; row < 4u; ++row) { + for (std::size_t column = 0u; column < 4u; ++column) { + const double value = matrix[column * 4u + row]; + if (!std::isfinite(value)) return false; + rows[row][column] = value; + scale = std::max(scale, std::abs(value)); + } + rows[row][4u + row] = 1.0; + } + if (!(scale > 0.0)) return false; + const double epsilon = scale * 1.0e-12; + for (std::size_t column = 0u; column < 4u; ++column) { + std::size_t pivot = column; + for (std::size_t row = column + 1u; row < 4u; ++row) { + if (std::abs(rows[row][column]) > std::abs(rows[pivot][column])) + pivot = row; + } + if (std::abs(rows[pivot][column]) <= epsilon) return false; + if (pivot != column) { + for (std::size_t entry = 0u; entry < 8u; ++entry) + std::swap(rows[pivot][entry], rows[column][entry]); + } + const double divisor = rows[column][column]; + for (double &entry : rows[column]) entry /= divisor; + for (std::size_t row = 0u; row < 4u; ++row) { + if (row == column) continue; + const double factor = rows[row][column]; + for (std::size_t entry = 0u; entry < 8u; ++entry) + rows[row][entry] -= factor * rows[column][entry]; + } + } + std::array result{}; + for (std::size_t row = 0u; row < 4u; ++row) { + for (std::size_t column = 0u; column < 4u; ++column) { + const double value = rows[row][4u + column]; + if (!std::isfinite(value)) return false; + result[column * 4u + row] = value; + } + } + inverse = result; + return true; +} + +bool create_cloud_target_pipeline(Dx12GeState &s, std::string &error) noexcept { + D3D12_GRAPHICS_PIPELINE_STATE_DESC pso{}; + pso.pRootSignature = s.root_signature.Get(); + pso.VS = {s.present_vertex_shader->GetBufferPointer(), + s.present_vertex_shader->GetBufferSize()}; + pso.PS = {s.cloud_target_pixel_shader->GetBufferPointer(), + s.cloud_target_pixel_shader->GetBufferSize()}; + pso.SampleMask = UINT_MAX; + pso.RasterizerState.FillMode = D3D12_FILL_MODE_SOLID; + pso.RasterizerState.CullMode = D3D12_CULL_MODE_NONE; + pso.RasterizerState.DepthClipEnable = TRUE; + auto &blend = pso.BlendState.RenderTarget[0]; + blend.BlendEnable = TRUE; + blend.SrcBlend = D3D12_BLEND_ONE; + blend.DestBlend = D3D12_BLEND_INV_SRC_ALPHA; + blend.BlendOp = D3D12_BLEND_OP_ADD; + blend.SrcBlendAlpha = D3D12_BLEND_ONE; + blend.DestBlendAlpha = D3D12_BLEND_INV_SRC_ALPHA; + blend.BlendOpAlpha = D3D12_BLEND_OP_ADD; + blend.RenderTargetWriteMask = D3D12_COLOR_WRITE_ENABLE_ALL; + pso.DepthStencilState.DepthEnable = TRUE; + pso.DepthStencilState.DepthWriteMask = D3D12_DEPTH_WRITE_MASK_ZERO; + // World targets are cleared to reverse-depth zero. Equality therefore + // restricts the fullscreen pass to pixels untouched by world geometry. + pso.DepthStencilState.DepthFunc = D3D12_COMPARISON_FUNC_EQUAL; + pso.DepthStencilState.StencilEnable = FALSE; + pso.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE; + pso.NumRenderTargets = 1u; + pso.RTVFormats[0] = kColorFormat; + pso.DSVFormat = s.depth_format; + pso.SampleDesc.Count = s.sample_count; + pso.SampleDesc.Quality = s.sample_quality; + const HRESULT hr = s.device->CreateGraphicsPipelineState( + &pso, IID_PPV_ARGS(&s.cloud_target_pipeline)); + if (FAILED(hr)) { + error = hr_text(hr, "CreateGraphicsPipelineState(DX12 GE cloud target)"); + return false; + } + return true; +} + +const CloudCameraCandidate *select_cloud_camera(const Dx12GeState &s) noexcept { + if (s.display_framebuffer == 0u) return nullptr; + std::vector ancestors; + ancestors.reserve(std::min(s.frame_targets.size() + 1u, + kFramebufferTargetCapacity)); + ancestors.push_back(s.display_framebuffer & 0x001FFFF0u); + const auto contains = [&](std::uint32_t address) { + address &= 0x001FFFF0u; + return std::find(ancestors.begin(), ancestors.end(), address) != ancestors.end(); + }; + bool changed = true; + while (changed && ancestors.size() < kFramebufferTargetCapacity) { + changed = false; + for (const Dx12Batch &batch : s.batches) { + if (!batch.framebuffer_feedback) continue; + const std::uint32_t source = batch.feedback_address & 0x001FFFF0u; + const std::uint32_t destination = batch.draw.framebuffer_address & 0x001FFFF0u; + if (source == destination || !contains(destination) || contains(source)) continue; + ancestors.push_back(source); + changed = true; + if (ancestors.size() == kFramebufferTargetCapacity) break; + } + } + + const CloudCameraCandidate *best = nullptr; + for (const CloudCameraCandidate &candidate : s.cloud_cameras) { + if (!contains(candidate.target) || candidate.occluding_weight == 0u) continue; + // Match the camera selector already proven by Project2DFX: the camera + // with the most genuinely occluding geometry wins, then total weight. + // Prioritising depth writes selected auxiliary/reflection passes in VCS. + if (best == nullptr || + candidate.occluding_weight > best->occluding_weight || + (candidate.occluding_weight == best->occluding_weight && + candidate.weight > best->weight)) { + best = &candidate; + } + } + return best; +} + +CloudPresentConstants cloud_present_constants(const Dx12GeState &s) noexcept { + CloudPresentConstants out{}; + const auto &config = vcs_configuration().volumetric_clouds; + if (!config.enabled || s.cloud_cameras.empty()) return out; + const CloudCameraCandidate *camera = select_cloud_camera(s); + if (camera == nullptr) return out; + + GeCloudCameraFrame frame{}; + if (!ge_cloud_camera_frame_from_view(camera->view, frame)) return out; + const Dx12FramebufferTarget *target = find_framebuffer_target(s, camera->target); + const float logical_width = static_cast(std::max( + 1u, target != nullptr ? target->logical_width : kReferenceWidth)); + const float logical_height = static_cast(std::max( + 1u, target != nullptr ? target->logical_height : kReferenceHeight)); + const float x_a = camera->viewport[0] * (2.0f / logical_width); + const float y_a = camera->viewport[1] * (2.0f / logical_height); + const float x_b = (camera->viewport[2] - camera->viewport[4]) * + (2.0f / logical_width) - 1.0f; + const float y_b = (camera->viewport[3] - camera->viewport[5]) * + (2.0f / logical_height) - 1.0f; + if (!std::isfinite(x_a) || !std::isfinite(y_a) || + std::abs(x_a) < 1.0e-6f || std::abs(y_a) < 1.0e-6f) + return out; + + // Fold the GE viewport into projection exactly as make_transform_constants + // does for native geometry. This eliminates the camera-relative drift that + // came from treating raw P00/P11 as if every pass occupied the full target. + std::array effective_projection{}; + for (std::size_t column = 0u; column < 4u; ++column) { + const std::size_t base = column * 4u; + effective_projection[base + 0u] = + x_a * camera->projection[base + 0u] + x_b * camera->projection[base + 3u]; + effective_projection[base + 1u] = + -y_a * camera->projection[base + 1u] - y_b * camera->projection[base + 3u]; + effective_projection[base + 2u] = camera->projection[base + 2u]; + effective_projection[base + 3u] = camera->projection[base + 3u]; + } + std::array inverse_projection{}; + if (!invert_cloud_matrix(effective_projection, inverse_projection)) return out; + + const auto view_direction = [&](double ndc_x, double ndc_y, + std::array &direction) { + constexpr double clip_z = 0.5; + const std::array clip{ndc_x, ndc_y, clip_z, 1.0}; + std::array point{}; + for (std::size_t row = 0u; row < 4u; ++row) { + for (std::size_t column = 0u; column < 4u; ++column) + point[row] += inverse_projection[column * 4u + row] * clip[column]; + } + if (!std::isfinite(point[3]) || std::abs(point[3]) < 1.0e-12) return false; + for (std::size_t axis = 0u; axis < 3u; ++axis) { + direction[axis] = point[axis] / point[3]; + if (!std::isfinite(direction[axis])) return false; + } + return true; + }; + std::array center_view{}, right_view{}, up_view{}; + if (!view_direction(0.0, 0.0, center_view) || + !view_direction(1.0, 0.0, right_view) || + !view_direction(0.0, 1.0, up_view)) return out; + const auto to_world = [&](const std::array &value) { + return std::array{ + frame.view_to_world[0] * value[0] + frame.view_to_world[1] * value[1] + + frame.view_to_world[2] * value[2], + frame.view_to_world[3] * value[0] + frame.view_to_world[4] * value[1] + + frame.view_to_world[5] * value[2], + frame.view_to_world[6] * value[0] + frame.view_to_world[7] * value[1] + + frame.view_to_world[8] * value[2]}; + }; + const std::array center_world = to_world(center_view); + const std::array right_world = to_world(right_view); + const std::array up_world = to_world(up_view); + for (std::size_t axis = 0u; axis < 3u; ++axis) { + const double ray_right = right_world[axis] - center_world[axis]; + const double ray_up = up_world[axis] - center_world[axis]; + if (!std::isfinite(ray_right) || !std::isfinite(ray_up) || + !std::isfinite(center_world[axis])) return {}; + out.ray_right_time[axis] = static_cast(ray_right); + out.ray_up_coverage[axis] = static_cast(ray_up); + out.ray_forward_opacity[axis] = static_cast(center_world[axis]); + out.camera_settings[axis] = camera->camera_position[axis]; + } + out.ray_right_time[3] = + static_cast(s.frame_epoch) * (1.0f / 60.0f) * config.speed; + out.ray_up_coverage[3] = config.coverage; + out.ray_forward_opacity[3] = config.opacity; + const std::uint32_t settings = + std::clamp(config.march_steps, 4u, 64u) | 0x100u; + out.camera_settings[3] = std::bit_cast(settings); + return out; +} + +void record_clouds_into_world_target(Dx12GeState &s, Dx12FramebufferTarget &target, + const CloudPresentConstants &clouds) noexcept { + prepare_target_for_render(s, target); + const D3D12_CPU_DESCRIPTOR_HANDLE rtv = rtv_cpu(s, target.rtv_index); + const D3D12_CPU_DESCRIPTOR_HANDLE dsv = dsv_cpu(s, target.dsv_index); + s.list->OMSetRenderTargets(1u, &rtv, FALSE, &dsv); + D3D12_VIEWPORT viewport{0.0f, 0.0f, static_cast(s.target_width), + static_cast(s.target_height), 0.0f, 1.0f}; + D3D12_RECT scissor{0, 0, static_cast(s.target_width), + static_cast(s.target_height)}; + s.list->RSSetViewports(1u, &viewport); + s.list->RSSetScissorRects(1u, &scissor); + s.list->SetPipelineState(s.cloud_target_pipeline.Get()); + s.list->SetGraphicsRootSignature(s.root_signature.Get()); + ID3D12DescriptorHeap *heaps[]{s.srv_heap.Get(), s.sampler_heap.Get()}; + s.list->SetDescriptorHeaps(2u, heaps); + s.list->SetGraphicsRootDescriptorTable(0u, srv_gpu(s, 0u)); + s.list->SetGraphicsRootDescriptorTable(1u, sampler_gpu(s, 0u)); + std::array constants{}; + std::memcpy(constants.data() + 5u, &clouds, sizeof(clouds)); + s.list->SetGraphicsRoot32BitConstants( + 3u, static_cast(constants.size()), constants.data(), 0u); + s.list->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + s.list->DrawInstanced(3u, 1u, 0u, 0u); +} + bool record_direct_present(Dx12GeState &s, Dx12FramebufferTarget &source, std::string &error) noexcept { if (!ensure_swapchain(s, error)) return false; @@ -1692,6 +1978,14 @@ bool record_direct_present(Dx12GeState &s, Dx12FramebufferTarget &source, s.list->SetDescriptorHeaps(2u, heaps); s.list->SetGraphicsRootDescriptorTable(0u, srv_gpu(s, source.srv_index)); s.list->SetGraphicsRootDescriptorTable(1u, sampler_gpu(s, present_sampler(s))); + // Clouds are rendered into the selected 3D world target before VCS samples + // it for composition. Applying them here would mix world-camera rays with + // final-display pixels and make the layer follow the screen. + const CloudPresentConstants clouds{}; + std::array present_constants{}; + std::memcpy(present_constants.data() + 5u, &clouds, sizeof(clouds)); + s.list->SetGraphicsRoot32BitConstants( + 3u, static_cast(present_constants.size()), present_constants.data(), 0u); s.list->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); s.list->DrawInstanced(3u, 1u, 0u, 0u); transition(s.list.Get(), backbuffer, D3D12_RESOURCE_STATE_RENDER_TARGET, @@ -2082,6 +2376,7 @@ void clear_accumulation(Dx12GeState &s) noexcept { s.packed_0115_vertices.clear(); s.indices.clear(); s.batches.clear(); + s.cloud_cameras.clear(); } bool create_backend(Dx12GeState &s, std::string &error) noexcept { @@ -2168,6 +2463,7 @@ bool create_backend(Dx12GeState &s, std::string &error) noexcept { if (!create_root_signature(s, error)) return false; if (!create_targets(s, error)) return false; if (!create_present_pipeline(s, error)) return false; + if (!create_cloud_target_pipeline(s, error)) return false; s.vertices.reserve(262144u); s.packed_0115_vertices.reserve(2621440u); s.indices.reserve(524288u); @@ -2208,6 +2504,8 @@ void destroy_backend(Dx12GeState &s) noexcept { s.swapchain.Reset(); s.swap_rtv_heap.Reset(); s.present_pipeline.Reset(); + s.cloud_target_pipeline.Reset(); + s.cloud_target_pixel_shader.Reset(); s.present_pixel_shader.Reset(); s.present_vertex_shader.Reset(); s.pipelines.clear(); @@ -2417,6 +2715,43 @@ void ge_gpu_backend_record_draw(const GeGpuDrawDescriptor &draw) noexcept { } } +void ge_gpu_backend_observe_camera(const std::array &view, + const std::array &projection, + const std::array &viewport, + const std::array &camera_position, + const GeGpuDrawDescriptor &draw, + std::uint32_t vertex_weight) noexcept { + Dx12GeState &s = state(); + if (!s.enabled || !vcs_configuration().volumetric_clouds.enabled || + vertex_weight == 0u || !draw.depth_test_enabled) return; + if (!std::all_of(view.begin(), view.end(), [](float value) { return std::isfinite(value); }) || + !std::all_of(projection.begin(), projection.end(), [](float value) { return std::isfinite(value); }) || + !std::all_of(viewport.begin(), viewport.end(), [](float value) { return std::isfinite(value); }) || + !std::all_of(camera_position.begin(), camera_position.end(), + [](float value) { return std::isfinite(value); })) + return; + const std::uint32_t target = draw.framebuffer_address & 0x001FFFF0u; + const auto found = std::find_if( + s.cloud_cameras.begin(), s.cloud_cameras.end(), + [&](const CloudCameraCandidate &candidate) { + return candidate.target == target && candidate.view == view && + candidate.projection == projection && candidate.viewport == viewport; + }); + if (found != s.cloud_cameras.end()) { + found->weight += vertex_weight; + found->camera_position = camera_position; + if ((draw.depth_function & 7u) >= 2u) found->occluding_weight += vertex_weight; + return; + } + // Normal gameplay has only a handful of camera variants per frame. A hard + // cap prevents malformed guest state from growing this host-only observer. + if (s.cloud_cameras.size() >= 16u) return; + const std::uint64_t occluding_weight = (draw.depth_function & 7u) >= 2u + ? vertex_weight : 0u; + s.cloud_cameras.push_back({view, projection, viewport, camera_position, + target, vertex_weight, occluding_weight}); +} + bool ge_gpu_backend_stage_vertices(const GeGpuDrawDescriptor &, std::span vertices) noexcept { Dx12GeState &s = state(); if (!s.enabled) return false; @@ -2976,10 +3311,42 @@ bool ge_gpu_backend_finish_color_frame(std::uint64_t vblank) noexcept { bool active_vertex_layout_valid = false; D3D12_PRIMITIVE_TOPOLOGY active_topology = D3D_PRIMITIVE_TOPOLOGY_UNDEFINED; bool touched_display = false; + const CloudCameraCandidate *cloud_camera = select_cloud_camera(s); + const CloudPresentConstants clouds = cloud_present_constants(s); + const std::uint32_t cloud_target_address = cloud_camera != nullptr + ? cloud_camera->target : 0u; + bool clouds_injected = false; constexpr float black[4]{0.0f, 0.0f, 0.0f, 1.0f}; for (const Dx12Batch &batch : s.batches) { const std::uint32_t address = batch.draw.framebuffer_address & 0x001FFFF0u; + // Insert immediately before the first non-self pass samples the chosen + // world target. At this point its geometry/depth are complete, while + // the later VCS composition has not consumed its color yet. + if (!clouds_injected && cloud_camera != nullptr && + batch.framebuffer_feedback && + (batch.feedback_address & 0x001FFFF0u) == cloud_target_address && + address != cloud_target_address) { + if (Dx12FramebufferTarget *cloud_target = + find_framebuffer_target(s, cloud_target_address); + cloud_target != nullptr && cloud_target->color && cloud_target->depth) { + if (current_target != nullptr && current_target != cloud_target) + resolve_target_for_sampling(s, *current_target, false); + record_clouds_into_world_target(s, *cloud_target, clouds); + current_target = cloud_target; + current_address = cloud_target_address; + clouds_injected = true; + active_pipeline = nullptr; + active_pipeline_key = std::numeric_limits::max(); + bound_srv = bound_sampler = std::numeric_limits::max(); + active_transform_valid = false; + active_pixel_valid = false; + active_scissor_valid = false; + active_blend_fix = std::numeric_limits::max(); + active_vertex_layout_valid = false; + active_topology = D3D_PRIMITIVE_TOPOLOGY_UNDEFINED; + } + } Dx12FramebufferTarget *target = address == current_address ? current_target : find_framebuffer_target(s, address); if (target == nullptr || !target->color || !target->depth) continue; @@ -3381,6 +3748,12 @@ bool ge_gpu_backend_active() noexcept { return false; } bool ge_gpu_backend_transfer_ready() noexcept { return false; } bool ge_gpu_backend_graphics_ready() noexcept { return false; } void ge_gpu_backend_record_draw(const GeGpuDrawDescriptor &) noexcept {} +void ge_gpu_backend_observe_camera(const std::array &, + const std::array &, + const std::array &, + const std::array &, + const GeGpuDrawDescriptor &, + std::uint32_t) noexcept {} bool ge_gpu_backend_stage_vertices(const GeGpuDrawDescriptor &, std::span) noexcept { return false; } bool ge_gpu_backend_texture_needed(const GeGpuDrawDescriptor &) noexcept { return false; } void ge_gpu_backend_prepare_texture_keys(GeGpuDrawDescriptor &) noexcept {} diff --git a/profiles/vcs/host/ge_renderer.cpp b/profiles/vcs/host/ge_renderer.cpp index 2322f45..1f733c6 100644 --- a/profiles/vcs/host/ge_renderer.cpp +++ b/profiles/vcs/host/ge_renderer.cpp @@ -4188,6 +4188,31 @@ bool render_ge_primitive(psprecomp::GuestMemory &memory, gpu_draw.texture_content_signature = any_signature ? signature : 0u; } ge_gpu_backend_record_draw(gpu_draw); + if (!gpu_draw.through && !gpu_draw.clear_mode && + primitive >= 3u && primitive <= 5u) { + const std::array cloud_viewport{ + decode_float24(data24(commands[0x42u])), + decode_float24(data24(commands[0x43u])), + decode_float24(data24(commands[0x45u])), + decode_float24(data24(commands[0x46u])), + static_cast(data24(commands[0x4Cu]) & 0xFFFFu) / 16.0f, + static_cast(data24(commands[0x4Du]) & 0xFFFFu) / 16.0f}; + // VCS' authoritative camera origin. The affine GE view used by + // individual passes is not guaranteed to encode this position as + // a rigid inverse (reflections and camera-relative passes do not), + // which made a world-space cloud slab orbit while only turning. + constexpr std::uint32_t kVcsCameraPosition = 0x08BC87E0u; + std::array cloud_camera_position{}; + if (memory.contains(kVcsCameraPosition, 12u)) { + cloud_camera_position = { + std::bit_cast(memory.load32(kVcsCameraPosition + 0u)), + std::bit_cast(memory.load32(kVcsCameraPosition + 4u)), + std::bit_cast(memory.load32(kVcsCameraPosition + 8u))}; + } + ge_gpu_backend_observe_camera(transform.view, transform.projection, + cloud_viewport, cloud_camera_position, + gpu_draw, count); + } if (!gpu_draw.clear_mode && primitive >= 3u && primitive <= 6u) fps_overlay_observe_draw(gpu_draw, count); if (!gpu_draw.through && !gpu_draw.clear_mode && diff --git a/profiles/vcs/host/vcs_config.cpp b/profiles/vcs/host/vcs_config.cpp index 899425a..f7d5fcb 100644 --- a/profiles/vcs/host/vcs_config.cpp +++ b/profiles/vcs/host/vcs_config.cpp @@ -106,12 +106,70 @@ bool parse_u64(std::string value, std::uint64_t minimum, std::uint64_t maximum, return true; } +bool parse_float(std::string value, float minimum, float maximum, float &out) { + value = trim_copy(std::move(value)); + if (value.empty()) return false; + errno = 0; + char *end = nullptr; + const float parsed = std::strtof(value.c_str(), &end); + if (errno == ERANGE || end == value.c_str() || *end != '\0' || + !std::isfinite(parsed) || parsed < minimum || parsed > maximum) { + return false; + } + out = parsed; + return true; +} + void warning(VcsConfiguration &config, std::size_t line, const std::string &message) { std::ostringstream stream; stream << "line " << line << ": " << message; config.warnings.push_back(stream.str()); } +void load_proper_shaders_configuration(VcsConfiguration &config, + const std::filesystem::path &path) { + std::ifstream input(path); + if (!input) return; + std::string section; + std::string raw_line; + std::size_t line_number = 0u; + while (std::getline(input, raw_line)) { + ++line_number; + std::string line = trim_copy(raw_line); + if (line.empty() || line[0] == ';' || line[0] == '#') continue; + if (line.front() == '[' && line.back() == ']') { + section = lowercase_copy(trim_copy(line.substr(1u, line.size() - 2u))); + continue; + } + if (section != "volumetricclouds" && section != "volumetric clouds") continue; + const std::size_t separator = line.find('='); + if (separator == std::string::npos) { + warning(config, line_number, "ProperShaders.ini: expected key=value"); + continue; + } + const std::string key = lowercase_copy(trim_copy(line.substr(0u, separator))); + const std::string value = strip_inline_comment(line.substr(separator + 1u)); + auto bad_float = [&](const char *name) { + warning(config, line_number, std::string("ProperShaders.ini: invalid ") + name); + }; + if (key == "enabled") { + if (!parse_bool(value, config.volumetric_clouds.enabled)) + warning(config, line_number, "ProperShaders.ini: Enabled expects true/false"); + } else if (key == "marchsteps") { + if (!parse_u32(value, 4u, 64u, config.volumetric_clouds.march_steps)) + warning(config, line_number, "ProperShaders.ini: MarchSteps must be between 4 and 64"); + } else if (key == "coverage") { + if (!parse_float(value, 0.0f, 1.0f, config.volumetric_clouds.coverage)) bad_float("Coverage"); + } else if (key == "opacity") { + if (!parse_float(value, 0.0f, 1.0f, config.volumetric_clouds.opacity)) bad_float("Opacity"); + } else if (key == "speed") { + if (!parse_float(value, 0.0f, 1.0f, config.volumetric_clouds.speed)) bad_float("Speed"); + } else { + warning(config, line_number, "ProperShaders.ini: unknown [VolumetricClouds] key '" + key + "'"); + } + } +} + void apply_display_key(VcsConfiguration &config, const std::string &key, const std::string &value, std::size_t line) { if (key == "enabled") { @@ -635,6 +693,7 @@ void initialize_vcs_configuration(const std::filesystem::path &executable_direct } VcsConfiguration loaded = load_vcs_configuration(path); + load_proper_shaders_configuration(loaded, executable_directory / "ProperShaders.ini"); loaded.initialized = true; loaded.executable_directory = executable_directory; { diff --git a/profiles/vcs/host/vcs_config.hpp b/profiles/vcs/host/vcs_config.hpp index 1bb0354..916d5ae 100644 --- a/profiles/vcs/host/vcs_config.hpp +++ b/profiles/vcs/host/vcs_config.hpp @@ -173,6 +173,17 @@ struct WidescreenConfiguration { std::uint32_t aspect_y{0u}; }; +// Standalone ProperShaders.ini feature. These values deliberately do not read +// the guest timecycle/weather: the first port is a fixed, independently +// configurable CloudWorks sky layer. +struct VolumetricCloudsConfiguration { + bool enabled{false}; + std::uint32_t march_steps{20u}; + float coverage{0.52f}; + float opacity{0.78f}; + float speed{0.018f}; +}; + // The aspect the game itself builds its projection with. VCS loads the // constant 0x3FE38E39 -- exactly 16/9 -- and everything is relative to it. inline constexpr float kGameNativeAspectRatio = 16.0f / 9.0f; @@ -211,6 +222,7 @@ struct VcsConfiguration { TimingConfiguration timing{}; DiagnosticsConfiguration diagnostics{}; WidescreenConfiguration widescreen{}; + VolumetricCloudsConfiguration volumetric_clouds{}; std::filesystem::path source_path{}; // Where the executable lives. Saves go beside it rather than into the game // data, so a player who points the runtime at a read-only or shared copy of diff --git a/profiles/vcs/tests/vcs_config_tests.cpp b/profiles/vcs/tests/vcs_config_tests.cpp index 4855724..53c1341 100644 --- a/profiles/vcs/tests/vcs_config_tests.cpp +++ b/profiles/vcs/tests/vcs_config_tests.cpp @@ -153,6 +153,23 @@ int main() { const vcs::VcsConfiguration missing = vcs::load_vcs_configuration(root / "missing.ini"); + { + std::ofstream proper(root / "ProperShaders.ini", std::ios::trunc); + proper << "[VolumetricClouds]\n" + << "Enabled=true\n" + << "MarchSteps=28\n" + << "Coverage=0.61\n" + << "Opacity=0.72\n" + << "Speed=0.03\n"; + } + vcs::initialize_vcs_configuration(root); + const auto &clouds = vcs::vcs_configuration().volumetric_clouds; + require(clouds.enabled, "ProperShaders.ini VolumetricClouds.Enabled was not parsed"); + require(clouds.march_steps == 28u, "ProperShaders.ini MarchSteps was not parsed"); + require(std::abs(clouds.coverage - 0.61f) < 0.0001f && + std::abs(clouds.opacity - 0.72f) < 0.0001f && + std::abs(clouds.speed - 0.03f) < 0.0001f, + "ProperShaders.ini cloud parameters were not parsed"); // Widescreen: explicit ratio, "auto", and off. The correction must be // exactly neutral when disabled -- PSP parity stays the baseline. require(config.widescreen.enabled, "Widescreen.Enabled was not parsed"); diff --git a/profiles/vcs/third_party/cloudworks/ATTRIBUTION.md b/profiles/vcs/third_party/cloudworks/ATTRIBUTION.md new file mode 100644 index 0000000..ee214f5 --- /dev/null +++ b/profiles/vcs/third_party/cloudworks/ATTRIBUTION.md @@ -0,0 +1,14 @@ +# CloudWorks attribution + +The volumetric-cloud density and noise model in the VCS DirectX 12 presenter is +adapted from **CloudWorks Alpha 4.0** by Brian Tu (RTU), dated 2021-07-25. + +Original project: + +The original shader identifies its license as Creative Commons +Attribution-NonCommercial-ShareAlike 3.0 Unported (CC BY-NC-SA 3.0): + + +This adaptation replaces RenderHook/timecycle inputs with fixed parameters from +`ProperShaders.ini`, uses the PSP GE camera captured by VCSNative, and composites +the result in the native DirectX 12 presentation pass.