本文将告诉大家如何在 WPF 中通过自定义 ShaderEffect 像素着色器实现图片的单应变换,也就是透视变换效果,拖动图片四个角点可以实时调整变换形态,整个过程由 GPU 逐像素完成,Demo 中附带了凸四边形校验,避免把图片拖成无法变换的形态
本文内容由人类主导 AI 辅助编写
背景
单应变换(Homography)是计算机视觉里很常用的变换,它的特点是能把一个任意四边形映射到另一个任意四边形,比如把一张贴纸图片投影到照片里的墙面、把倾斜拍摄的文档矫正成正视图,都属于单应变换的应用。数学上它由一个 3x3 的矩阵描述,和普通的仿射变换不同,这个矩阵还带有透视分量,因此能把平行线变成相交线,产生近大远小的效果
在 WPF 里面想要做出这种透视效果,常见的选择是用 PlaneProjection 或者 3D 场景里的相机投影。但这两个方案本质上都是把平面放到空间里旋转,能模拟的形态有限,而且想要”任意四边形到任意四边形”的自由映射并不方便。另一个思路是使用像素着色器:每个输出像素都去计算自己对应源纹理的哪个位置,然后把颜色取过来,这是完全自由的,只要数学算得出来,什么变换都能做
WPF 原生支持自定义 ShaderEffect,只需要把编译好的像素着色器字节码交给 PixelShader 即可。本文的 Demo 就是一个完整的可运行示例,界面如下:中间是图片,四角有黄色圆环手柄,拖动手柄图片实时变形,虚线框标出当前四边形,四边形之外的区域保持棋盘格背景
整体流程
先看一下整个 Demo 的工作流程,方便理解后面每一段代码所处的位置:
- 在 XAML 里放置图片、棋盘格背景、四边形轮廓和四个拖拽手柄
- 拖拽手柄时,把拖动量换算成归一化的角点坐标,更新角点数组
- 校验四个角点是否构成凸四边形,不合法则回滚
- 用角点计算”四边形到单位正方形”的逆单应矩阵
- 把矩阵三行传给 ShaderEffect 的常量寄存器
- GPU 运行像素着色器,逐像素从源纹理取色完成变换
其中第 4 步的矩阵计算是整个 Demo 的核心,第 6 步的着色器是效果的关键,拖拽交互只是把这两者串起来
角点的坐标系约定
打开 MainWindow.xaml.cs,可以看到角点数据保存在 _corners 数组里,四个点的顺序是左上、右上、右下、左下,也就是顺时针:
private readonly Point[] _corners =
[
new(0.08, 0.10),
new(0.78, 0.04),
new(0.94, 0.82),
new(0.12, 0.94)
];
这里非常关键的一点是:坐标全部归一化到 0 到 1 之间,而不是直接使用像素值。为什么要这么做?因为单应矩阵的数学推导是基于单位正方形(0 到 1 的坐标系)的,归一化之后,无论窗口怎么缩放、Viewport 是多大,矩阵计算都不受影响,只在最后把坐标乘回 Viewport 的宽高用于绘制。这个解耦让整个 Demo 简单了很多
单应矩阵的计算
单应变换的矩阵计算分为两步:先算”单位正方形到四边形”的正向矩阵,再对它求逆得到”四边形到单位正方形”的逆矩阵。代码里 Homography 静态类封装了全部数学逻辑
先看正向矩阵 CreateUnitSquareToQuadrilateral,它接收四个角点,返回一个 3x3 矩阵:
private static double[,] CreateUnitSquareToQuadrilateral(
Point topLeft,
Point topRight,
Point bottomRight,
Point bottomLeft)
{
var dx1 = topRight.X - bottomRight.X;
var dx2 = bottomLeft.X - bottomRight.X;
var dx3 = topLeft.X - topRight.X + bottomRight.X - bottomLeft.X;
var dy1 = topRight.Y - bottomRight.Y;
var dy2 = bottomLeft.Y - bottomRight.Y;
var dy3 = topLeft.Y - topRight.Y + bottomRight.Y - bottomLeft.Y;
var denominator = (dx1 * dy2) - (dx2 * dy1);
if (Math.Abs(denominator) < Epsilon)
{
throw new ArgumentException("四边形无法构成有效的单应变换。");
}
var g = ((dx3 * dy2) - (dx2 * dy3)) / denominator;
var h = ((dx1 * dy3) - (dx3 * dy1)) / denominator;
return new[,]
{
{ topRight.X - topLeft.X + (g * topRight.X), bottomLeft.X - topLeft.X + (h * bottomLeft.X), topLeft.X },
{ topRight.Y - topLeft.Y + (g * topRight.Y), bottomLeft.Y - topLeft.Y + (h * bottomLeft.Y), topLeft.Y },
{ g, h, 1 }
};
}
这段代码看起来有点吓人,其实它是在解一个线性方程组。单应矩阵的第三行是 (g, h, 1),其中的 g 和 h 就是透视分量,它们决定了变换的”倾斜”程度。dx1、dx2、dx3 这些中间量分别是四边形的对角差和”歪斜度”(topLeft - topRight + bottomRight - bottomLeft 衡量的是四边形偏离平行四边形的程度),代入公式就能解出 g 和 h
这个矩阵的计算方式不是原创的,而是来自 OpenCV 的 getPerspectiveTransform 实现,是图像处理领域非常经典的公式,直接把四个目标角点代进去就能得到映射矩阵
拿到 g 和 h 之后,矩阵的前两行也就定了。denominator 是分母,如果它接近 0,说明四个点退化成了三角形或者共线,这时候矩阵无解,直接抛异常告诉调用方四边形不合法
有了正向矩阵之后,为什么还需要求逆?这就要回到像素着色器的工作方式了。着色器是对输出图像的每个像素执行的:它知道目标像素的坐标(destination),但不知道这个位置应该取源纹理的哪个颜色。所以我们需要的是反过来的映射,也就是”输出图像上的点,对应源纹理的哪个位置”,这就是 CreateQuadrilateralToUnitSquare 要做的事:
public static double[,] CreateQuadrilateralToUnitSquare(IReadOnlyList<Point> corners)
{
ArgumentNullException.ThrowIfNull(corners);
if (corners.Count != 4)
{
throw new ArgumentException("必须提供四个角点。", nameof(corners));
}
var forward = CreateUnitSquareToQuadrilateral(
corners[0], corners[1], corners[2], corners[3]);
return Invert(forward);
}
它的实现很朴素:先算正向矩阵,再调用 Invert 求逆。求逆用的是 3x3 矩阵的伴随矩阵法,对于 3x3 这种小矩阵,直接按公式展开比通用高斯消元更快也更直观:
private static double[,] Invert(double[,] matrix)
{
var a = matrix[0, 0];
var b = matrix[0, 1];
var c = matrix[0, 2];
var d = matrix[1, 0];
var e = matrix[1, 1];
var f = matrix[1, 2];
var g = matrix[2, 0];
var h = matrix[2, 1];
var i = matrix[2, 2];
var determinant = (a * ((e * i) - (f * h)))
- (b * ((d * i) - (f * g)))
+ (c * ((d * h) - (e * g)));
if (Math.Abs(determinant) < Epsilon)
{
throw new InvalidOperationException("单应矩阵不可逆。");
}
var scale = 1 / determinant;
return new[,]
{
{ ((e * i) - (f * h)) * scale, ((c * h) - (b * i)) * scale, ((b * f) - (c * e)) * scale },
{ ((f * g) - (d * i)) * scale, ((a * i) - (c * g)) * scale, ((c * d) - (a * f)) * scale },
{ ((d * h) - (e * g)) * scale, ((b * g) - (a * h)) * scale, ((a * e) - (b * d)) * scale }
};
}
行列式接近 0 说明矩阵奇异,也就是变换会压扁成一条线,此时同样抛异常。这里的 Epsilon 是 1e-8,一个足够小的阈值,用来判断浮点数是否为 0,避免直接比较 == 0 的浮点精度问题
凸四边形校验
矩阵算好了,但如果用户把某个角点拖过头,四个点可能不再构成凸四边形,此时单应变换会得到扭曲甚至翻转的怪异结果。所以在拖拽过程中要实时校验,不合法就回滚。这就是 IsConvexQuadrilateral 的作用:
public static bool IsConvexQuadrilateral(IReadOnlyList<Point> corners)
{
ArgumentNullException.ThrowIfNull(corners);
if (corners.Count != 4)
{
return false;
}
double? sign = null;
for (var i = 0; i < corners.Count; i++)
{
var first = corners[i];
var second = corners[(i + 1) % corners.Count];
var third = corners[(i + 2) % corners.Count];
var cross = ((second.X - first.X) * (third.Y - second.Y))
- ((second.Y - first.Y) * (third.X - second.X));
if (Math.Abs(cross) < 0.002)
{
return false;
}
var currentSign = Math.Sign(cross);
sign ??= currentSign;
if (sign != currentSign)
{
return false;
}
}
return true;
}
判断凸四边形的原理很简单:遍历每条边,计算相邻两条边向量的叉积。叉积的符号表示转动的方向,如果所有边的转动方向一致,也就是叉积符号全部相同,那这个四边形就是凸的。(i + 1) % corners.Count 和 (i + 2) % corners.Count 是取模运算,让最后一个点能绕回第一个点,这样四条边都能遍历到
叉积绝对值小于 0.002 时直接判定不合法,这表示两条边几乎共线,四边形退化成了接近三角形或者自交的形态,虽然数学上还能算出矩阵,但视觉效果会很奇怪。这个阈值可以根据手感调整,太大会让角点拖到边缘就拖不动,太小则容易拖出怪异的形状
像素着色器
数学部分完成之后,就轮到真正的核心:像素着色器。整个 HLSL 源码就内嵌在 C# 的字符串里,这也是这个 Demo 最大的亮点,先看着色器代码:
sampler2D InputTexture : register(s0);
float4 MatrixRow0 : register(c0);
float4 MatrixRow1 : register(c1);
float4 MatrixRow2 : register(c2);
float4 main(float2 destination : TEXCOORD0) : COLOR0
{
float3 homogeneousPosition = float3(destination, 1.0);
float sourceW = dot(MatrixRow2.xyz, homogeneousPosition);
float2 source = float2(
dot(MatrixRow0.xyz, homogeneousPosition),
dot(MatrixRow1.xyz, homogeneousPosition)) / sourceW;
float2 lowerBound = step(float2(0.0, 0.0), source);
float2 upperBound = step(source, float2(1.0, 1.0));
float visible = lowerBound.x * lowerBound.y * upperBound.x * upperBound.y;
return tex2D(InputTexture, saturate(source)) * visible;
}
逐行解释一下。第一行声明了输入采样器 InputTexture 绑定到 s0 寄存器,这是 WPF 传入的源图像。接下来三行把矩阵的三行声明为 float4 常量,绑定到 c0、c1、c2 寄存器,WPF 会通过常量寄存器把 C# 侧的矩阵数据传进来
main 函数的输入 destination 是目标像素坐标,WPF 渲染时会把它归一化到 0 到 1 范围。把它补上齐次坐标的第三个分量 1.0,就得到 homogeneousPosition
接下来的三行就是把逆矩阵应用到坐标上:
float sourceW = dot(MatrixRow2.xyz, homogeneousPosition);
float2 source = float2(
dot(MatrixRow0.xyz, homogeneousPosition),
dot(MatrixRow1.xyz, homogeneousPosition)) / sourceW;
矩阵乘齐次坐标之后得到一个三维向量,前两个分量是未归一化的源坐标,第三行算出的 sourceW 是透视分量,用它做除法,把齐次坐标还原成普通的二维坐标,这就是透视除法。source 就是当前输出像素对应的源纹理位置
最后几行处理越界问题:
float2 lowerBound = step(float2(0.0, 0.0), source);
float2 upperBound = step(source, float2(1.0, 1.0));
float visible = lowerBound.x * lowerBound.y * upperBound.x * upperBound.y;
return tex2D(InputTexture, saturate(source)) * visible;
当 source 落在 0 到 1 之外时,说明这个输出像素在目标四边形外面,那里不应该显示图片内容。step(a, b) 在 b >= a 时返回 1,否则返回 0,四个边界条件乘起来,落在四边形内的像素 visible 为 1,在外的为 0。最后用 saturate(source) 把采样坐标夹到合法范围避免越界采样,再乘上 visible,四边形外的像素 alpha 变成 0,露出下面的棋盘格背景
注意这里选用的着色器模型是 ps_2_0,这是 WPF ShaderEffect 支持的传统着色器模型,指令数有限,但应付这个效果绰绰有余
运行时编译着色器
传统 WPF 的 ShaderEffect 用法是先用 fxc.exe 把 .fx 文件预编译成 .ps 字节码文件,作为资源嵌入程序集,再通过 PixelShader 的 UriSource 加载。这个流程需要额外的构建步骤和文件管理,比较繁琐
这个 Demo 换了一种思路:把 HLSL 源码直接写成 C# 字符串,程序启动时调用 DirectX 的 D3DCompile 在运行时编译。这样整个项目不需要任何 .fx 文件,也不需要预编译步骤,代码即着色器,自包含且容易修改。RuntimePixelShaderCompiler 类封装了这一切
D3DCompile 是 d3dcompiler_47.dll 导出的函数,通过 P/Invoke 调用:
[DllImport("d3dcompiler_47.dll", CharSet = CharSet.Ansi)]
private static extern int D3DCompile(
byte[] sourceData,
nuint sourceDataSize,
string sourceName,
IntPtr defines,
IntPtr include,
string entryPoint,
string target,
uint flags1,
uint flags2,
[MarshalAs(UnmanagedType.Interface)] out ID3DBlob? code,
[MarshalAs(UnmanagedType.Interface)] out ID3DBlob? errors);
d3dcompiler_47.dll 在 Windows 8.1 及之后的系统都自带,Win10 和 Win11 无需额外安装。entryPoint 传 "main",target 传 "ps_2_0",编译成功后字节码写入 code 指向的 ID3DBlob,失败时错误信息写入 errors
ID3DBlob 是 DirectX 的 COM 接口,用于存放编译产物,但它在 C# 里没有现成的互操作定义,需要自己声明。整个接口只有两个方法,取数据指针和取数据大小:
[ComImport]
[Guid("8BA5FB08-5195-40E2-AC58-0D989C3A0102")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface ID3DBlob
{
[PreserveSig]
IntPtr GetBufferPointer();
[PreserveSig]
nuint GetBufferSize();
}
[ComImport] 和 [Guid] 告诉运行时这是一个 COM 接口,[PreserveSig] 表示方法直接返回原始值而不是按 HRESULT 约定包装。这是 COM 互操作里比较容易踩坑的地方,少了 [PreserveSig] 运行时会试图把返回值当作 HRESULT 处理,导致拿不到正确的数据指针
编译的主流程如下:
public static PixelShader Compile()
{
var source = Encoding.UTF8.GetBytes(ShaderSource);
var result = D3DCompile(
source, (nuint)source.Length, "PerspectiveEffect.fx", IntPtr.Zero, IntPtr.Zero,
"main", "ps_2_0", 0, 0, out var shaderBlob, out var errorBlob);
try
{
if (result < 0 || shaderBlob is null)
{
var message = errorBlob is null
? $"D3DCompile 失败,HRESULT: 0x{result:X8}。"
: ReadBlobText(errorBlob);
throw new InvalidOperationException(message, Marshal.GetExceptionForHR(result));
}
var byteCode = new byte[checked((int)shaderBlob.GetBufferSize())];
Marshal.Copy(shaderBlob.GetBufferPointer(), byteCode, 0, byteCode.Length);
var pixelShader = new PixelShader();
using var stream = new MemoryStream(byteCode, writable: false);
pixelShader.SetStreamSource(stream);
return pixelShader;
}
finally
{
Release(errorBlob);
Release(shaderBlob);
}
}
D3DCompile 返回的是 HRESULT,负数表示失败。失败时优先从 errorBlob 里读出错误文本,能直接看到 HLSL 的编译报错,这对调试着色器非常重要,不然只能看到一个没有信息的 HRESULT
编译成功之后,把 ID3DBlob 里的字节码复制到托管数组,包成 MemoryStream 交给 PixelShader.SetStreamSource。这里的 checked((int)shaderBlob.GetBufferSize()) 是为了防止在 32 位环境下指针大小转换溢出
finally 块里释放两个 COM 对象很关键。ID3DBlob 是 COM 对象,不释放会一直占用内存,而 Release 方法做了类型检查再调用 Marshal.ReleaseComObject:
private static void Release(object? value)
{
if (value is not null && Marshal.IsComObject(value))
{
Marshal.ReleaseComObject(value);
}
}
封装 ShaderEffect
像素着色器编译好之后,需要一个 WPF 的 ShaderEffect 子类把它包装起来,才能在 XAML 里使用。PerspectiveEffect 类就是干这个的
静态字段保存编译结果,整个程序只需要编译一次:
private static readonly PixelShader Shader = RuntimePixelShaderCompiler.Compile();
接着注册依赖属性。InputProperty 用 RegisterPixelShaderSamplerProperty 注册,这是采样器属性,对应 HLSL 里的 s0 寄存器:
public static readonly DependencyProperty InputProperty = RegisterPixelShaderSamplerProperty(
nameof(Input), typeof(PerspectiveEffect), 0);
矩阵的三行各用一个 Point4D 类型的依赖属性,通过 PixelShaderConstantCallback 绑定到常量寄存器:
public static readonly DependencyProperty MatrixRow0Property = DependencyProperty.Register(
nameof(MatrixRow0), typeof(Point4D), typeof(PerspectiveEffect),
new UIPropertyMetadata(new Point4D(1, 0, 0, 0), PixelShaderConstantCallback(0)));
public static readonly DependencyProperty MatrixRow1Property = DependencyProperty.Register(
nameof(MatrixRow1), typeof(Point4D), typeof(PerspectiveEffect),
new UIPropertyMetadata(new Point4D(0, 1, 0, 0), PixelShaderConstantCallback(1)));
public static readonly DependencyProperty MatrixRow2Property = DependencyProperty.Register(
nameof(MatrixRow2), typeof(Point4D), typeof(PerspectiveEffect),
new UIPropertyMetadata(new Point4D(0, 0, 1, 0), PixelShaderConstantCallback(2)));
为什么用 Point4D 而不是直接传 3x3 矩阵?因为 GPU 常量寄存器是按 float4 对齐的,PixelShaderConstantCallback 只能传 float4,所以把矩阵的每一行放进一个 Point4D,第四分量闲置为 0。默认值是单位矩阵,这样即使还没设置矩阵,效果也不会把图片弄丢
构造函数里把编译好的着色器赋给 PixelShader,并刷新所有属性的值,让默认值生效:
public PerspectiveEffect()
{
PixelShader = Shader;
UpdateShaderValue(InputProperty);
UpdateShaderValue(MatrixRow0Property);
UpdateShaderValue(MatrixRow1Property);
UpdateShaderValue(MatrixRow2Property);
}
最后提供 SetInverseMatrix 方法,把 3x3 矩阵拆成三行填进 Point4D:
public void SetInverseMatrix(double[,] matrix)
{
ArgumentNullException.ThrowIfNull(matrix);
MatrixRow0 = new Point4D(matrix[0, 0], matrix[0, 1], matrix[0, 2], 0);
MatrixRow1 = new Point4D(matrix[1, 0], matrix[1, 1], matrix[1, 2], 0);
MatrixRow2 = new Point4D(matrix[2, 0], matrix[2, 1], matrix[2, 2], 0);
}
注意传入的是逆矩阵,因为着色器需要的是”输出像素到源纹理”的映射。赋值依赖属性会自动触发 PixelShaderConstantCallback,把数据传到 GPU 的常量寄存器,无需手动刷新
界面布局
界面布局在 MainWindow.xaml 里。窗口采用深色主题,核心区域是一个固定逻辑尺寸 900x560 的 Viewport,外层套 Viewbox 让它随窗口缩放:
<Viewbox Stretch="Uniform">
<Grid x:Name="Viewport" Width="900" Height="560" ClipToBounds="True">
...
</Grid>
</Viewbox>
ClipToBounds="True" 防止变换后的图片画出边界。Viewport 内部依次堆叠三层内容:最底层是棋盘格背景,中间是图片,最上层是交互层
棋盘格背景用 DrawingBrush 平铺实现,模仿设计软件里的透明棋盘,用来衬托四边形外的透明区域:
<Border>
<Border.Background>
<DrawingBrush TileMode="Tile" Viewport="0,0,24,24" ViewportUnits="Absolute">
<DrawingBrush.Drawing>
<DrawingGroup>
<GeometryDrawing Brush="#242424" Geometry="M0,0 H24 V24 H0 Z" />
<GeometryDrawing Brush="#333333" Geometry="M0,0 H12 V12 H0 Z M12,12 H24 V24 H12 Z" />
</DrawingGroup>
</DrawingBrush.Drawing>
</DrawingBrush>
</Border.Background>
</Border>
24x24 的方块,左边画深色,右边画浅色,形成棋盘格
图片层直接显示一张示例图,Stretch="Fill" 让图片填满整个 Viewport,这样单应变换的输入就是整张图:
<Image x:Name="PreviewImage"
Source="/Image.png"
Stretch="Fill"
RenderOptions.BitmapScalingMode="HighQuality" />
最上层的 Canvas 放四边形轮廓和四个拖拽手柄。轮廓是虚线 Polygon,手柄是四个 Thumb,用 Tag 标记角点索引:
<Canvas x:Name="AdornerCanvas" Background="Transparent">
<Polygon x:Name="Outline"
Fill="Transparent"
Stroke="#F9AB00"
StrokeThickness="2"
StrokeDashArray="5,3"
IsHitTestVisible="False" />
<Thumb x:Name="TopLeftThumb" Tag="0" Style="{StaticResource CornerThumbStyle}"
DragDelta="CornerThumb_OnDragDelta" />
<Thumb x:Name="TopRightThumb" Tag="1" Style="{StaticResource CornerThumbStyle}"
DragDelta="CornerThumb_OnDragDelta" />
<Thumb x:Name="BottomRightThumb" Tag="2" Style="{StaticResource CornerThumbStyle}"
DragDelta="CornerThumb_OnDragDelta" />
<Thumb x:Name="BottomLeftThumb" Tag="3" Style="{StaticResource CornerThumbStyle}"
DragDelta="CornerThumb_OnDragDelta" />
</Canvas>
Thumb 是 WPF 自带的可拖拽控件,非常适合做这种手柄。它的外观通过 CornerThumbStyle 定制成黄色圆环加白色描边,Cursor 设为 SizeAll 提示可拖拽
拖拽交互
交互逻辑在 MainWindow.xaml.cs 里。所有 Thumb 共用同一个 DragDelta 事件处理函数,通过 Tag 区分是哪个角点:
private void CornerThumb_OnDragDelta(object sender, DragDeltaEventArgs e)
{
var thumb = (Thumb)sender;
var index = int.Parse((string)thumb.Tag);
var current = _corners[index];
var candidate = new Point(
Math.Clamp(current.X + (e.HorizontalChange / Viewport.Width), 0, 1),
Math.Clamp(current.Y + (e.VerticalChange / Viewport.Height), 0, 1));
var previous = _corners[index];
_corners[index] = candidate;
if (!Homography.IsConvexQuadrilateral(_corners))
{
_corners[index] = previous;
return;
}
UpdateVisuals();
}
DragDelta 给的是本次拖动的像素位移,把它除以 Viewport 的宽高,就换算成了归一化坐标的增量,和 _corners 的坐标系保持一致。Math.Clamp 把角点限制在 0 到 1 范围内,防止拖出图片区域
这里有一个很妙的细节:先更新角点,再校验凸四边形,如果校验失败就把角点回滚到拖动前的值。也就是说,允许用户把角点拖到”边缘极限”的合法位置,但一旦越过凸四边形的底线,就停留在最后合法状态,不会出现无法恢复的畸形变换
最后调用 UpdateVisuals 刷新界面。这个方法把”计算矩阵”和”绘制 UI”串联起来:
private void UpdateVisuals()
{
var inverse = Homography.CreateQuadrilateralToUnitSquare(_corners);
_effect.SetInverseMatrix(inverse);
var points = _corners
.Select(point => new Point(point.X * Viewport.Width, point.Y * Viewport.Height))
.ToArray();
Outline.Points = new PointCollection(points);
PositionThumb(TopLeftThumb, points[0]);
PositionThumb(TopRightThumb, points[1]);
PositionThumb(BottomRightThumb, points[2]);
PositionThumb(BottomLeftThumb, points[3]);
}
先用当前角点算出逆矩阵设置到 Effect,这一行就完成了”数据到 GPU”的传递,着色器马上会用新矩阵重新渲染。然后把归一化坐标乘回 Viewport 的宽高,得到像素坐标,用于更新虚线轮廓和四个手柄的位置。手柄定位时减去 ThumbRadius,让手柄圆心对准角点:
private static void PositionThumb(Thumb thumb, Point point)
{
Canvas.SetLeft(thumb, point.X - ThumbRadius);
Canvas.SetTop(thumb, point.Y - ThumbRadius);
}
构造函数里把 Effect 挂到图片上,并做一次初始绘制:
public MainWindow()
{
InitializeComponent();
PreviewImage.Effect = _effect;
UpdateVisuals();
}
到这里整个 Demo 就闭环了:拖动任意手柄,角点数组变化,矩阵重算,着色器重绘,图片实时变形
总结
这个 Demo 麻雀虽小五脏俱全,串起了三条技术线:单应矩阵的数学计算、HLSL 像素着色器、WPF ShaderEffect 与 COM 互操作。其中最容易踩坑的是运行时编译那一段,ID3DBlob 的 COM 接口声明、[PreserveSig] 特性、COM 对象释放,任何一个疏漏都会导致编译失败或内存泄漏,建议直接复用本文的封装
如果想要完全了解代码的每个细节,本文末尾有获取全部代码的方法
本文代码放在 github 和 gitee 上,可以使用如下命令行拉取代码。我整个代码仓库比较庞大,使用以下命令行可以进行部分拉取,拉取速度比较快
先创建一个空文件夹,接着使用命令行 cd 命令进入此空文件夹,在命令行里面输入以下代码,即可获取到本文的代码
git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin f3a5a4e0c4572d7b4e10ce7dc3cb5496714a9629
以上使用的是国内的 gitee 的源,如果 gitee 不能访问,请替换为 github 的源。请在命令行继续输入以下代码,将 gitee 源换成 github 源进行拉取代码。如果依然拉取不到代码,可以发邮件向我要代码
git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin f3a5a4e0c4572d7b4e10ce7dc3cb5496714a9629
获取代码之后,进入 WPFDemo/HomographyShaderEffectDemo 文件夹,即可获取到源代码
更多技术博客,请参阅 博客导航
本文会经常更新,请阅读原文: https://blog.lindexi.com/post/WPF-%E4%BD%BF%E7%94%A8%E5%83%8F%E7%B4%A0%E7%9D%80%E8%89%B2%E5%99%A8%E5%AE%9E%E7%8E%B0%E5%8D%95%E5%BA%94%E5%8F%98%E6%8D%A2.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。
如果你想持续阅读我的最新博客,请点击 RSS 订阅,推荐使用RSS Stalker订阅博客,或者收藏我的博客导航
本作品采用
知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议
进行许可。欢迎转载、使用、重新发布,但务必保留文章署名林德熙(包含链接:
https://blog.lindexi.com
),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请
与我联系
。
无盈利,不卖课,做纯粹的技术博客
以下是广告时间
推荐关注 Edi.Wang 的公众号
欢迎进入 Eleven 老师组建的 .NET 社区
以上广告全是友情推广,无盈利