前提声明:无论这篇文章是好是坏,我都不希望这篇文章变成付费的,仅作为个人开发时遇到的技术难题,提供一种解决方案,希望可以帮助到需要的人。

文本使用的项目资源:夸克网盘,无提取码
链接:https://pan.quark.cn/s/85160e69ac0d


问题引入一下

        我在使用WPF开发一款类似WallPaperEngine的壁纸编辑器,又同时借鉴了冰与火之舞这款游戏的场景编辑器的设计思想。
        具体来说,用户可以通过导入图片,将图片作为3D模型放入到3D视口中,利用透视相机(PerspectiveCamera),实现不同Z坐标的图片具有近大远小的距离感。
        可以看一下我开发的软件原型示例

图一        

        解释一下:只是截取了部分界面,右侧的设置参数区域并负责打开图片,设置完3D模型参数,最后放置到左侧的3D视口(Viewport3D)。
        你应该已经看到了,我先放置了一个6角圆角星图片(PNG格式,除了白色部分其余都是透明的),后放置了一个渐变色图片,前者距离相机更近一些。但是前面的模型按理来说透明的部分,应该是可以透过去看到后面被遮挡的渐变色图片的部分的实则不然,6角圆角星图片遮挡了后面的图片,透明部分直接透到了视口的背景色。

解决方案

        首先你需要了解一些信息,那就是WPF的3D视口和HelixToolkit的WPF原生版以及本文标题提到的HelixToolKit.Wpf.SharpDX版本的3D视口渲染原理和差别。


        如果你清楚WPF的3D视口的渲染逻辑以及Helix包的渲染逻辑,这部分可以不看,直接看项目实战部分。



        不要担心,我不会说复杂的底层渲染逻辑,因为我也不会。我只从感性的角度简要说明我的理解,或许会有偏差,如有错误请行内专业人士指正。

解决信息差

WPF的3D视口底层渲染逻辑

        WPF的<Viewport3D>控件,采用 操纵GPU的技术:DirectX,WPF使用DirectX9,算是比较陈旧的版本了,这也意味着WPF的3D视口功能和渲染上都比较受限。

[DesignerSerializationVisibility(DesignerSerializationVisibility.Content)]
public Visual3DCollection Children => (Visual3DCollection)GetValue(ChildrenProperty);

        这个是Viewport3D控件的源码,里面有一个Children属性,就是它来保存我们放置的模型。
        你可以看到,他是一个数组(Collection)。所以你有必要知道WPF是怎么通过它来渲染的:
其实逻辑很简单:我们放入模型,就是直接在Children后面插入一个模型(Visual3D),WPF采用从数组最后一个元素开始绘制,最后绘制第一个元素
        其实也好理解,在WPF原生的3D视口中,所有模型,包括Light,也属于Visual3D的部分,所以,你放置的直射光也会被插入到Children数组中。那当然了,光照一般都会第一个放入视口,不然没有光你怎么看到模型?
        

<Viewport3D Grid.Column="1" x:Name="_ViewPortForGame" ClipToBounds="True">
    <Viewport3D.Camera>
        <PerspectiveCamera Position="{Binding CameraPosition}"/>
    </Viewport3D.Camera>
    <ModelVisual3D>
        <ModelVisual3D.Content>
            <DirectionalLight Direction="0,0,-1" Color="White"/>
        </ModelVisual3D.Content>
    </ModelVisual3D>
</Viewport3D>


        所以,为了保证所有模型都要被光照射,光要最后渲染,所以第一个模型一定是最后才渲染的。
        所以你才会看到文章开始的问题,6角圆角星图片遮挡了后面的图片,透明部分直接透到了视口的背景色,就是因为6角圆角星是Children[1],渐变色图片是Children[2],所以先渲染了Children[2],后渲染了Children[1],我一开始使用的是漫反射材质(DiffuseMaterial),所以出现了这种不符合现实的效果。(后来我试过了使用别的WPF原生材质,比如SpecularMaterial (高光材质)虽然这种材质可以实现渐变色背景不被遮挡,但是效果不是很理想,这种材质使用了简单的像素加法,如果模型叠加,像素值会做加法,最大为255,这就导致,很多个3D模型一叠加,会有一大块白色的地方,而不是正确显示每个模型

HelixTookKit的底层渲染原理

        你需要下载对应的拓展包:


图二


        HelixToolkit基于WPF原生的视口,封装了很多使用的功能,比如坐标轴显示,相机的详细信息卡片,视角立方体。还有许多封装好的3D模型(如Teaport一个茶壶),读者可以自行探索。
        该包基于WPF原生的3D视口开发,所以渲染逻辑和WPF原生相同,而且,并没有提供更多的材质。
        但是这并不妨碍你使用HelixToolkit这个包进行3D模型开发,相信很多业内人士都会使用这个包进行工业3D建模等用途。因为这个包在原生的基础上做了许多性能优化,同时提供了许多快捷键手势的接口,你可以想玩游戏一样,移动你的视角。而且使用了线性插值算法,让你的移动更丝滑。
        总之,HelixToolkit包很常用,但是并不能解决我们的问题。

HelixToolkit.Wpf.SharpDX包的底层渲染逻辑

首先,你需要安装这些包:


图三

总共5个,可能有些不是必须,但是装上最好。
我把官方项目文档放在这里http://www.helix-toolkit.org
那么这个包做了什么呢?

1. 渲染引擎变化

        这个包的版本使用了SharpDX工具。主要采用了DirectX11版本,是比较靠前的版本。
        你可能会担心,你的电脑会不会不支持DirectX11的API。你可以查看自己电脑的DirectX配置,具体方法如下:

        Windows操作系统:键入WIN+R,输入dxdiag

图四       

然后会弹出相关配置:


图五

看到你的“DirectX版本”就知道你电脑的图形渲染API版本了。

        DirectX11有许多更为强大的渲染算法,在本文主要使用了其中的顺序无关透明度 (Order Independent Transparency,OIT)
        下面我给出Deepseek对此的介绍:
 

🤔 为什么需要 OIT?

传统的 3D 渲染在处理透明物体时,要求开发者严格按照从后往前的顺序来渲染它们,这样颜色混合才会正确。例如,你必须先画后面的山,再画前面的透明玻璃。

但是,当场景变得复杂,或者物体自身有交叉(比如互相缠绕的丝带)时,手动排序就变得非常困难甚至不可能。一旦顺序出错,就会出现“透过玻璃看到的不是山,而是天空盒”这类视觉错误,这正是你最初遇到的那个“Bug”。

而 OIT 的核心目的,就是为了打破这种“必须按顺序渲染”的约束。它能让 GPU 在渲染时,不依赖于我们指定的渲染顺序,也能最终计算出正确的透明混合效果。

⚙️ OIT 是怎么做到的?(不同流派)

你可以简单理解:有各种不同流派的技术来实现 OIT,而 DepthPeeling 只是其中之一。以下是几种主流的 OIT 实现方法对比:

深度剥离 (Depth Peeling) 🧅

  • 简单理解: 像剥洋葱一样,一层层“剥离”出距离相机最近的透明层,然后从后往前混合它们。

  • 运行时多 Pass: ✅ 是 (N层 → N个Pass)

  • 硬件要求: DX9

  • 优点: 算法直观,结果精确,对硬件要求低。

  • 缺点性能开销巨大,每剥离一层都需要重新渲染一遍整个场景。

  • 适用场景: 对渲染质量要求极高但对性能要求不高的场景(如 CAD 模型预览)。

逐像素链表 (Per-Pixel Linked Lists) 🔗

  • 简单理解: 为屏幕上的每个像素都创建一个“小本本”,把所有相关的透明片元都记录下来,最后一起排序混合。

  • 运行时多 Pass: ❌ 否 (2 Pass)

  • 硬件要求DX11+

  • 优点性能远比深度剥离高,因为只完整渲染场景一次。

  • 缺点: 需要对 GPU 的显存消耗进行预估算,可能会占用较多显存。

  • 适用场景: 适合大多数现代 3D 应用。

加权混合 (Weighted Blended OIT) ⚖️

  • 简单理解: 用一套复杂的数学公式“估算”出最终的透明颜色,而不是精确计算。

  • 运行时多 Pass: ❌ 否 (1 Pass)

  • 硬件要求: DX11+

  • 优点性能最高,只需一次渲染就能完成。

  • 缺点: 结果是近似值,对于层次非常复杂的透明物体可能会出现视觉瑕疵。

  • 适用场景: 对性能有极致要求的场景,如游戏特效。

     

        2. API简要介绍

        这里全是我的个人理解啊,可以不做标准参考,但是可能会对你的理解有一些帮助。

        或许你接触过Hlsl语言,在WPF中,你有时会有自己写ShaderEffect来实现更为好看的效果。那么如何写自己写ShaderEffect呢?就需要用到Hlsl语言,本文不介绍,读者可以自己去查看别的文章或者AI搜索。
        关于Hlsl语言,如果你了解了,你就知道,你会在开头先声明一个采样器(Sampler)

sampler2D Input : register(s0);

float4 main(float2 uv : TEXCOORD) : COLOR
{
	float4 color = tex2D(Input, uv);
	float gray = color.r * 0.299 + color.g * 0.587 + color.b * 0.114;
	return float4(gray, gray, gray, color.a);
}

这是一个实现灰度效果的Hlsl代码,你不必看懂这些代码,你只需要知道他干了什么。

1. sampler2D Input : register(s0); 这个就是采样器,就类似于你把你的图像,或者控件,或者3D模型,作为Input,被hlsl采样,说白了就是输入你的被渲染对象。

2. float4 main(float2 uv : TEXCOORD) : COLOR : 这个是hlsl的入口函数,它使用uv坐标。比方说,你的图片,为1800x1000的大小,他会把你的图片归一化,从一个宽1800px,高1000的矩形变换到1x1的正方形,这么做就是为了统一坐标表示。
更专业的说法:        uv 是纹理映射的坐标指针,由模型顶点的 UV 数据经过光栅化插值后自动提供给像素着色器,用于从纹理中提取颜色。

上面的没看懂也没关系,总之你需要意识到SharpDX使用了采样器和uv变换

这也是它和WPF原生的3D视口不同的地方,因为他们底层的渲染逻辑完全不同

特性 WPF 原生 (Media3D) SharpDX (如 HelixToolkit.Wpf.SharpDX)
渲染 API 基于 DirectX 9 (早期) / DirectX 9Ex(兼容 DX11 部分特性) 直接使用 DirectX 11/12
渲染模式 保留模式:构建 Visual Tree → MILCore 编译成 DX 命令 → GPU 即时模式:通过 SharpDX 直接调用 DX11 API,每帧提交绘制指令
透明混合 依赖物体渲染顺序,手动排序,无内置 OIT 支持 OIT (Depth Peeling, Weighted Blended),自动处理复杂透明
材质系统 简单材质(Diffuse, Specular, Emissive) 高级材质(Phong, PBR, Unlit),支持自定义 Shader
性能 低(受限于 WPF 渲染管道,大量 Draw Call 开销) (接近原生 DX11 性能,支持实例化、延迟渲染)
适用场景 轻度 3D 展示(柱状图、简单模型) 复杂 3D 场景(CAD、GIS、游戏、科学可视化)

项目案例

        相信你看过前面的介绍,或许对这个包有了感性的认识,接下来我会提供实际的例子来解决开篇遇到的问题。

        1. 新建WPF项目

        2. 安装NuGet包,参考上文[图三]。

       添加包引用:xmlns:hdx="http://helix-toolkit.org/wpf/SharpDX"(注意一定是hdx这个名字,官方对此的约定,非SharpDX的Helix包引用时,名字写“hx”)

<Window x:Class="SharpDX渲染框架使用示例.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:hdx="http://helix-toolkit.org/wpf/SharpDX"
        mc:Ignorable="d"
        Title="3D图片放置工具" Height="600" Width="900"
        Closing="Window_Closing"
        Loaded="Window_Loaded">
    <Grid>
        ...
    </Grid>
</Window>

        3. 构建UI(本文的UI使用AI编写)

        图六

4. 代码需要注意的地方

(1)内存管理

值得注意的是:在Xaml中,声明3D视口的Xaml代码:

<hdx:Viewport3DX x:Name="MyViewport"
                 Grid.Column="1"
                 BackgroundColor="#222222"
                 ShowCoordinateSystem="True"
                 ShowFrameRate="True"
                 ShowCameraInfo="True"
                 OITRenderMode="DepthPeeling"
                 EnableDeferredRendering="True"
                 EnableSwapChainRendering="False"
                 CoordinateSystemLabelForeground="AliceBlue"
                 EffectsManager="{Binding EffectsManager}"
                 MSAA="Maximum">
    <hdx:Viewport3DX.DefaultCamera>
        <hdx:PerspectiveCamera Position="0,0,15" LookDirection="0,0,-1" FieldOfView="45"/>
    </hdx:Viewport3DX.DefaultCamera>
    <hdx:Viewport3DX.Camera>
        <hdx:PerspectiveCamera Position="0,0,15" LookDirection="0,0,-1" FieldOfView="45"/>
    </hdx:Viewport3DX.Camera>
</hdx:Viewport3DX>

由于SharpDX调用GPU的内存管理,属于非托管内存,所以你需要手动管理视口的内存。

你必须在后端编写 EffectsManager="{Binding EffectsManager}",这个属性是负责接管WPF和GPU渲染管线的桥梁。如果没有写这个,你的视口将一直会是黑屏,就像这样


EffectMagaer就是初始化视口的对象,你必须手动管理他的生命周期,防止进程结束后内存残留。

using HelixToolkit;
using HelixToolkit.Geometry;
using HelixToolkit.Maths;
using HelixToolkit.SharpDX;
using HelixToolkit.Wpf.SharpDX;
using SharpDX;
using System;
using System.Collections.Generic;
using System.Numerics;
using System.Windows;
using System.Windows.Media.Media3D;

namespace SharpDX渲染框架使用示例
{
    public partial class MainWindow : Window
    {
        public DefaultEffectsManager EffectsManager { get; } = new DefaultEffectsManager();
        public MainWindow()
        {     
            InitializeComponent();
        }
        private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
        {
            MyViewport?.Dispose();
        }
    }
}
(2)渲染模式属性设置

写上这个,你的视口就能正常工作了(你也可以使用MVVM进行绑定,不过你需要手动管理它)

还有,你必须设置这四个负责渲染模式的属性:

OITRenderMode="DepthPeeling" <!-- 设置OIT模式,解决透明部分遮挡问题-->

EnableDeferredRendering="True" <!--开启延迟渲染,提高性能-->

EnableSwapChainRendering="True" <!--开启后,控件放在顶层,不会被其他控件遮盖,性能高;关闭后,可以被其他控件遮盖,性能低-->

MSAA="Maximum" <!--抗锯齿-->

其余属性都是视口的辅助信息显示属性,根据自己需要设置。

(3)关于相机

你可能会发现,我设置了两个相机

<hdx:Viewport3DX.DefaultCamera>
        <hdx:PerspectiveCamera Position="0,0,15" LookDirection="0,0,-1" FieldOfView="45"/>
    </hdx:Viewport3DX.DefaultCamera>
    <hdx:Viewport3DX.Camera>
        <hdx:PerspectiveCamera Position="0,0,15" LookDirection="0,0,-1" FieldOfView="45"/>
    </hdx:Viewport3DX.Camera>
</hdx:Viewport3DX.DefaultCamera>

这两者的区别在于:Camera是当前实际用于渲染的相机;而DefaultCamera是一个备份,用于在用户进行“重置视图”(Zoom Extents)等操作时,将相机恢复到你预设的初始位置。

在后端代码设置相机参数的时候,通常使用这种方法设置:

 public void ReSetCameraLookDir(double ms = 1000.0)
 {
     if (MyViewport.Camera is HelixToolkit.Wpf.SharpDX.PerspectiveCamera camera)
     {
         camera.ChangeDirection(new Vector3D(0,0,-1), new Vector3D(0,1,0), ms);
     }
 }

这是一个重置镜头的函数。

(4)模型生成算法流程

1. 使用MeshBuilder类,创建模型几何体,并生成面和法线,这里使用Cube立方体,只在Cube的朝前方的面设置法线。

2. 使用MeshBuilder的对象生成MeshGeometry3D几何体

3. 设置几何体的位置参数(根据图片大小和设置的模型坐标值来设置位置)

4. 生成纹理TextureModel,纹理是材质的一部分,就像之前说的,hlsl工作原理,你需要采样器采集纹理,然后进行渲染。

5. 生成材质,需要用到之前生成的纹理。SharpDX封装了更多材质,这里使用PhongMaterial材质,对于它的参数,我简要说明:
        


 var material = new PhongMaterial
 {
     EmissiveMap = textureModel,  //自发光的纹理
     EmissiveColor = new Color4(1, 1, 1, (float)opacity), //自发光的光线参与模型ARGB通道渲染的权值
     DiffuseMap = textureModel, //漫反射的纹理
     DiffuseColor = new Color4(0, 0, 0, (float)opacity),  // 漫反射光线对模型渲染ARGB通道的权值
     AmbientColor = new Color4(0, 0, 0, 1),
     SpecularColor = new Color4(0, 0, 0, 1)
 };

对于*Map,就是某种光打在模型体上的纹理,比如,你有一个直射光线,直射光线照在你的模型上,反射出来的纹理是什么。比方说,太阳光打在叶子上,叶子接受光,通过漫反射,返回了绿色和叶脉的纹理。
对于*Color,就是不同种光线打在模型上,模型对该种光线的反射程度,比方说,亮着的LED灯,明显是自发光要比外界光照射在他上面多得多,所以你看到的就是它自发光的纹理和颜色偏多,漫反射渲染出来的纹理权重就很少了。

6. 创建模型MeshGeometryModel3D,需要用到之前的几何体和材质,

这里最关键!一定要设置IsTransparent = true ;这是解决的核心。

7. 可选部分,设置模型的渲染变换属性,如果你有需要控制模型变换的需求,记得加上。


        

private MeshGeometryModel3D CreateImageModel(string path, double width, double height,
    double posX, double posY, double posZ,
    double rotX, double rotY, double rotZ,
    double opacity)
{
    // 1. 使用 AddCube(BoxFaces.Front) 创建前面
    var meshBuilder = new MeshBuilder();
    meshBuilder.AddCube(BoxFaces.Front);
    var geometry = meshBuilder.ToMeshGeometry3D();

    // 2. 重新设置顶点位置,调整为指定宽高
    float halfW = (float)width / 2;
    float halfH = (float)height / 2;
    geometry.Positions = new Vector3Collection
    {
        new Vector3(-halfW, -halfH, 0),
        new Vector3( halfW, -halfH, 0),
        new Vector3( halfW,  halfH, 0),
        new Vector3(-halfW,  halfH, 0)
    };

    // 3. 加载纹理(关键:第二个参数为 false,禁用预乘Alpha,防止透明区域变黑)
    var textureModel = new TextureModel(path);

    // 4. 创建材质:同时使用 EmissiveMap + DiffuseMap,并将漫反射颜色设为黑色,自发光颜色设为白色,
    //    这样材质不受外部光照影响,直接显示纹理原色,并且透明度正确。
    var material = new PhongMaterial
    {
        EmissiveMap = textureModel,
        EmissiveColor = new Color4(1, 1, 1, (float)opacity),
        DiffuseMap = textureModel,
        DiffuseColor = new Color4(0, 0, 0, (float)opacity),  // 漫反射贡献为0
        AmbientColor = new Color4(0, 0, 0, 1),
        SpecularColor = new Color4(0, 0, 0, 1)
    };

    // 5. 创建模型
    var model = new MeshGeometryModel3D
    {
        Geometry = geometry,
        Material = material,
        IsTransparent = true   // 告诉渲染器这是透明物体
    };

    // 6. 设置变换(位置 + 旋转)
    var transformGroup = new Transform3DGroup();
    transformGroup.Children.Add(new TranslateTransform3D(posX, posY, posZ));
    if (rotZ != 0)
        transformGroup.Children.Add(new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(0, 0, 1), rotZ)));
    if (rotY != 0)                transformGroup.Children.Add(new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(0, 1, 0), rotY)));
    if (rotX != 0)
        transformGroup.Children.Add(new RotateTransform3D(new AxisAngleRotation3D(new Vector3D(1, 0, 0), rotX)));
    model.Transform = transformGroup;

    return model;
}

5. 项目演示


可以看到,前者没有把后面的图片遮挡,问题得以解决。

后记

SharpDX内置了很多快捷键,比如按下W,S,A,D,Q,Z,可以移动相机位置。右键鼠标移动可以旋转视角。按住Alt并右键鼠标移动可以调整视野。上下左右键也可以转动视野。

你可以自己探索更多功能。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐