Skip to content

Commit 1e91a8a

Browse files
Spruill-1Copilot
andcommitted
Split Comparison: host-injected output dimensions
D2D pads pixel-shader intermediates to atlas allocation sizes (typically 4096x4096) when an effect output has multiple downstream consumers, so HLSL `Texture2D::GetDimensions()` returns the atlas size rather than the true output rect. The previous Split Comparison fix (TRIVIAL_SAMPLING) got `uv0` into output coordinates, but `GetDimensions()` was still reporting 4096x4096 -- so the recentering math `uv0 - (W*0.5, H*0.5)` landed the pivot at (2048, 2048), placing the wipe seam at the bottom edge of a 3840x2160 video. Reproducer that I missed last round: two branches forking from the same upstream effect (e.g. video -> HDR Tone Map -> {Gamut Highlight, Gamut Highlight}) feeding the two Split inputs. With a single branch (video -> tone map -> split, with video also direct -> split), D2D doesn't atlas-pad and GetDimensions matches the rect, so the pivot was correct. The user's full graph has the fork pattern, exposing the bug. Fix: dont rely on GetDimensions. Add `OutputW` / `OutputH` (float) hidden cbuffer fields to the Split shader, and have GraphEvaluator's pixel-shader EvaluateNode case populate them each frame from the upstream input's `GetImageLocalBounds`. Generic mechanism: any pixel shader that declares `OutputW` / `OutputH` parameters gets host-injected dimensions automatically, gated on the parameter declaration so other effects pay no per-frame cost. Bumped Split Comparison effectVersion 4 -> 5. Also added /render/image-bounds MCP route to expose the raw pre-clamp GetImageLocalBounds for debugging rect-bloat issues that the capture-node maxDim clamp would otherwise hide. Verified: white line at y=1080 of a 2160-tall video output (was at the bottom edge before). 154/154 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bad629d commit 1e91a8a

3 files changed

Lines changed: 105 additions & 13 deletions

File tree

Effects/ShaderLabEffects.cpp

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2392,6 +2392,11 @@ cbuffer Constants : register(b0)
23922392
float Angle; // degrees; 0 = horizontal wipe (vertical line),
23932393
// 90 = vertical wipe (horizontal line),
23942394
// 45 = top-left-to-bottom-right diagonal
2395+
float OutputW; // host-injected: actual output rect width in
2396+
// pixels. Cant use Texture2D::GetDimensions()
2397+
// because D2D pads intermediates to atlas
2398+
// allocation sizes (typically 4096x4096).
2399+
float OutputH; // host-injected: actual output rect height.
23952400
};
23962401
23972402
float4 main(
@@ -2401,23 +2406,18 @@ float4 main(
24012406
float4 a = ImageA.Load(int3(uv0.xy, 0));
24022407
float4 b = ImageB.Load(int3(uv0.xy, 0));
24032408
2404-
uint w, h;
2405-
ImageA.GetDimensions(w, h);
2406-
float W = float(w);
2407-
float H = float(h);
2409+
// Use the host-supplied output dimensions, not GetDimensions(), since
2410+
// D2D pads input textures to atlas allocation sizes (e.g. 4096x4096
2411+
// when the actual output rect is 3840x2160). uv0 is in true pixel
2412+
// coords thanks to D2D1_PIXEL_OPTIONS_TRIVIAL_SAMPLING; we just need
2413+
// the matching output-rect dimensions to recenter on.
2414+
float W = max(OutputW, 1.0);
2415+
float H = max(OutputH, 1.0);
24082416
24092417
// Direction vector along which we project pixel positions.
24102418
float radians = Angle * 3.14159265 / 180.0;
24112419
float2 dir = float2(cos(radians), sin(radians));
24122420
2413-
// For an effect with inputs (Split has 2), `uv0` (TEXCOORD0) is the
2414-
// scene/pixel-space position the inputs are sampled at, so it
2415-
// matches the output rect 1:1. SV_POSITION isnt reliable here --
2416-
// for some D2D draw configurations its in clip space, not pixel
2417-
// space, and recentering on (W*0.5, H*0.5) lands the pivot in
2418-
// the wrong place. (Source-like effects without inputs *can* use
2419-
// SV_POSITION reliably, but thats a separate code path.)
2420-
//
24212421
// Project the pixel coord (relative to image center) onto dir.
24222422
// SplitPosition = 0..1 sweeps the wipe across the full extent of
24232423
// the image *along* the dir vector, with 0.5 always pivoting on
@@ -2443,7 +2443,7 @@ float4 main(
24432443

24442444
ShaderLabEffectDescriptor desc;
24452445
desc.name = L"Split Comparison";
2446-
desc.effectId = L"Split Comparison"; desc.effectVersion = 4;
2446+
desc.effectId = L"Split Comparison"; desc.effectVersion = 5;
24472447
desc.category = L"Analysis";
24482448
desc.subcategory = L"Comparison";
24492449
desc.shaderType = Graph::CustomShaderType::PixelShader;
@@ -2453,6 +2453,10 @@ float4 main(
24532453
{ L"SplitPosition", L"float", 0.5f, 0.0f, 1.0f, 0.01f },
24542454
{ L"LineWidth", L"float", 2.0f, 0.0f, 10.0f, 0.5f },
24552455
{ L"Angle", L"float", 0.0f, -360.0f, 360.0f, 1.0f },
2456+
// Hidden: host writes actual output-rect dimensions
2457+
// each frame (see GraphEvaluator's pixel-shader eval).
2458+
Graph::ParameterDefinition{ L"OutputW", L"float", 1.0f, 1.0f, 16384.0f, 1.0f, {}, L"", true },
2459+
Graph::ParameterDefinition{ L"OutputH", L"float", 1.0f, 1.0f, 16384.0f, 1.0f, {}, L"", true },
24562460
};
24572461
m_effects.push_back(std::move(desc));
24582462
}

Engine/Mcp/EngineMcpRoutes.cpp

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,6 +1138,40 @@ namespace ShaderLab::Mcp
11381138
});
11391139
});
11401140
}
1141+
// ---- POST /render/image-bounds — return raw GetImageLocalBounds -----
1142+
// Body: { nodeId }. Returns { width, height } at 96 DPI in pixels.
1143+
// Useful for diagnosing rect-bloat issues that the capture-node
1144+
// route hides via its maxDim clamp.
1145+
void RegisterImageBounds(McpHttpServer& server, IEngineCommandSink& sink)
1146+
{
1147+
server.AddRoute(L"POST", L"/render/image-bounds",
1148+
[&sink](const std::wstring&, const std::string& body) -> McpHttpServer::Response
1149+
{
1150+
return sink.Dispatch([&body](EngineContext& ctx) -> McpHttpServer::Response {
1151+
WDJ::JsonObject jo{ nullptr };
1152+
if (!WDJ::JsonObject::TryParse(winrt::to_hstring(body), jo))
1153+
return Json(400, R"({"error":"Invalid JSON body"})");
1154+
if (!jo.HasKey(L"nodeId"))
1155+
return Json(400, R"({"error":"'nodeId' is required"})");
1156+
uint32_t nodeId = static_cast<uint32_t>(jo.GetNamedNumber(L"nodeId"));
1157+
if (ctx.renderFrame) ctx.renderFrame();
1158+
auto* node = ctx.graph->FindNode(nodeId);
1159+
if (!node || !node->cachedOutput)
1160+
return Json(404, R"({"error":"Node not ready"})");
1161+
float oldDpiX = 0, oldDpiY = 0;
1162+
ctx.dc->GetDpi(&oldDpiX, &oldDpiY);
1163+
ctx.dc->SetDpi(96.0f, 96.0f);
1164+
D2D1_RECT_F bounds{};
1165+
ctx.dc->GetImageLocalBounds(node->cachedOutput, &bounds);
1166+
ctx.dc->SetDpi(oldDpiX, oldDpiY);
1167+
return Json(200, std::format(
1168+
R"({{"left":{},"top":{},"right":{},"bottom":{},"width":{},"height":{}}})",
1169+
bounds.left, bounds.top, bounds.right, bounds.bottom,
1170+
bounds.right - bounds.left, bounds.bottom - bounds.top));
1171+
});
1172+
});
1173+
}
1174+
11411175
// ---- POST /render/capture-node — render any node to PNG ------------
11421176
// Body: { nodeId, inline?:bool }. Saves PNG to %TEMP%; returns
11431177
// path + size. If inline=true, also returns a base64 PNG payload.
@@ -1599,6 +1633,7 @@ namespace ShaderLab::Mcp
15991633
RegisterGetGraph(server, sink);
16001634
RegisterCustomEffects(server, sink);
16011635
RegisterAnalysisOutput(server, sink);
1636+
RegisterImageBounds(server, sink);
16021637
RegisterCaptureNode(server, sink);
16031638
RegisterCompileEffect(server, sink);
16041639
RegisterGetDisplayProfiles(server, sink);

Rendering/GraphEvaluator.cpp

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,59 @@ namespace ShaderLab::Rendering
538538
bool bindingsChanged = ResolveBindings(*node, graph, effectiveProps);
539539
bool wasDirty = node->dirty || bindingsChanged;
540540

541+
// Inject host-driven output dimensions for shaders that
542+
// declare OutputW / OutputH cbuffer fields. D2D pads input
543+
// textures to atlas allocation sizes (typically 4096x4096),
544+
// so HLSL `Texture2D::GetDimensions()` is unreliable for
545+
// anything that needs the true output rect (e.g. Split
546+
// Comparisons center-of-image pivot). The shader uses
547+
// these values instead.
548+
bool declaresOutputDims = false;
549+
if (node->customEffect->shaderType == Graph::CustomShaderType::PixelShader)
550+
{
551+
for (const auto& p : node->customEffect->parameters)
552+
{
553+
if (p.name == L"OutputW" || p.name == L"OutputH")
554+
{ declaresOutputDims = true; break; }
555+
}
556+
}
557+
if (declaresOutputDims)
558+
{
559+
auto inputs = graph.GetInputEdges(nodeId);
560+
if (!inputs.empty())
561+
{
562+
auto* srcNode = graph.FindNode(inputs[0]->sourceNodeId);
563+
if (srcNode && srcNode->cachedOutput && dc)
564+
{
565+
float oldDpiX = 0, oldDpiY = 0;
566+
dc->GetDpi(&oldDpiX, &oldDpiY);
567+
dc->SetDpi(96.0f, 96.0f);
568+
D2D1_RECT_F bounds{};
569+
dc->GetImageLocalBounds(srcNode->cachedOutput, &bounds);
570+
dc->SetDpi(oldDpiX, oldDpiY);
571+
float w = bounds.right - bounds.left;
572+
float h = bounds.bottom - bounds.top;
573+
if (w > 0 && h > 0)
574+
{
575+
auto wIt = effectiveProps.find(L"OutputW");
576+
auto hIt = effectiveProps.find(L"OutputH");
577+
bool changed =
578+
(wIt == effectiveProps.end() ||
579+
!std::holds_alternative<float>(wIt->second) ||
580+
std::get<float>(wIt->second) != w) ||
581+
(hIt == effectiveProps.end() ||
582+
!std::holds_alternative<float>(hIt->second) ||
583+
std::get<float>(hIt->second) != h);
584+
effectiveProps[L"OutputW"] = w;
585+
effectiveProps[L"OutputH"] = h;
586+
node->properties[L"OutputW"] = w;
587+
node->properties[L"OutputH"] = h;
588+
if (changed) wasDirty = true;
589+
}
590+
}
591+
}
592+
}
593+
541594
// Write resolved binding values back to node properties
542595
// so the node graph UI shows live bound values.
543596
if (bindingsChanged)

0 commit comments

Comments
 (0)