简介:一份基于Direct3D 9.0的太阳系三维模拟工程,用Visual C++ 10构建,实现行星轨道、自转与公转的实时可视化。项目面向3D图形编程初学者及游戏开发爱好者,重点演示摄像机动态控制、开普勒椭圆轨道、纹理贴图与天空盒背景等核心技术,可作为计算机图形学课程设计或D3D入门实践的参考。压缩包共109个文件,包含C++源文件(.cpp/.h)、可执行程序(.exe)、纹理素材(.jpg)以及完整工程配置(.sln/.vcxproj)等,整体大小14.2MB,已有547人学习下载。代码结构清晰,涵盖窗口创建、D3D初始化、几何体绘制与相机交互等模块,工程可直接编译运行,便于对照研究行星运动模拟的数学建模与渲染管线的衔接。
1. 先把太阳系装进D3D场景:一个轨道自转公转的C++演示
提到“太阳系”可视化,多数人先想到Unity或Three.js。这个D3D项目选了最底层的路线:用C++和Direct3D 11,不碰引擎,从零把太阳和八颗行星渲染成可交互的三维场景。核心功能就三件——行星沿轨道绕太阳公转、每颗行星绕自身轴自转、相机在轨道内外自由漫游。此外,所有天体的半径、轨道半径、公转/自转速度、纹理都是参数化配置,改一行数据就能把地球公转周期拉长十倍。项目适合刚学完D3D 11、想找完整案例对照的图形学入门者,也适合需要课设底稿、想快速改成自己交互逻辑的开发者。代码量不大,但D3D里最常踩坑的环节,它都实实在在走了一遍。
2. 技术栈与整体架构:从D3D11初始化到帧循环
2.1 技术选型:为什么是D3D11而不是引擎或D3D9
Direct3D 11相比9有几个实打实的优势。第一,着色器模型升级到5.0,行星纹理采样、颜色输出这些效果写起来更直接;第二,调试工具链完整,VS自带的图形诊断能逐帧看每个常量值,排查黑屏比D3D9时代舒服得多;第三,不再依赖早已废弃的D3DX辅助库,纹理加载、数学运算都由自己显式引入,代码更透明,出了问题能顺着调用链摸。
为什么不直接用Unity?因为引擎把设备创建、资源管理、渲染状态全封装成了黑匣子,拖个材质球,行星就“自己转起来”了,但你对D3D管线本身的理解仍然是空的。这个项目的价值恰恰在于把“设备—资源—着色器—绘制调用”这条链路完整露出来。
至于D3D12,性能上限更高,但线程安全和资源状态管理复杂度远超入门阶段的目标。D3D11用单一设备上下文,驱动帮你处理大部分状态过渡,是图形学教学阶段那个“压得住的复杂度”。
2.2 工程目录与模块划分
一份典型的D3D11太阳系工程,会按这样的方式组织文件:
D3DSolarSystem/ ├── src/ │ ├── D3DApp.h/.cpp # 设备、交换链、深度缓冲的创建与封装 │ ├── Camera.h/.cpp # 相机漫游、鼠标/键盘控制 │ ├── Planet.h/.cpp # 行星数据、公转/自转更新、渲染 │ ├── SolarSystem.h/.cpp # 管理所有行星实例,驱动Update和Render │ └── main.cpp # Win32窗口、消息循环、帧循环 ├── shaders/ │ ├── PlanetVS.hlsl # 顶点着色器:世界矩阵+视图投影 │ ├── PlanetPS.hlsl # 像素着色器:纹理采样 │ └── SunPS.hlsl # 太阳的自发光效果 └── textures/ # 每颗行星一张表面贴图模块职责就三句话。D3DApp把环境配好,是典型的样板代码;Planet是核心,一颗行星一个实例;SolarSystem是调度者,逐帧让每个Planet先Update再Render。Camera独立成类,是因为交互逻辑(WASD移动、右键拖拽转向)和渲染逻辑解耦之后,你再想加“锁定行星跟随”功能就很轻松。
2.3 设备创建与交换链配置
D3DApp里最开始那几十行代码,决定了后面所有渲染能否正常跑。我一般会按以下配置来创建设备和交换链,这也是项目里最可能采用的方式:
// D3DApp.cpp —— 设备与交换链创建(核心节选) HRESULT CreateDeviceAndSwapChain(HWND hWnd, UINT width, UINT height) { DXGI_SWAP_CHAIN_DESC sd = {}; sd.BufferCount = 2; // 双缓冲,避免画面撕裂 sd.BufferDesc.Width = width; sd.BufferDesc.Height = height; sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; sd.OutputWindow = hWnd; sd.SampleDesc.Count = 1; // 不开启MSAA sd.SampleDesc.Quality = 0; sd.Windowed = TRUE; D3D_FEATURE_LEVEL levels[] = { D3D_FEATURE_LEVEL_11_0 }; HRESULT hr = D3D11CreateDeviceAndSwapChain( nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, levels, 1, D3D11_SDK_VERSION, &sd, &m_swapChain, &m_device, &m_featureLevel, &m_context ); return hr; }这里几个参数是有讲究的。BufferCount = 2表示双缓冲,游戏循环里Present后前一个缓冲区正在显示,后一个给GPU渲染,不会出现撕裂感;如果设为3,画面延迟会更明显但帧率波动更平滑。SampleDesc的Count和Quality是MSAA开关,Count=1代表不做抗锯齿。太阳系里的球体边缘有锯齿可以接受,因为MSAA意味着渲染目标和深度缓冲的格式都要换,复杂度会整体上升。
Feature Level选了D3D_FEATURE_LEVEL_11_0,约束了着色器模型为5.0,同时兼容近十年内的主流显卡。初始化完成之后还需要创建ID3D11RenderTargetView和ID3D11DepthStencilView,前者决定颜色写到哪里,后者供深度测试使用,后续渲染的每一帧都要同时绑定它们。
2.4 帧循环与渲染命令的先后
main.cpp的循环不用消息驱动,而是事件驱动——没有Windows消息需要处理时立即进入渲染帧。这是D3D程序的典型写法:
// main.cpp —— 帧循环(节选) while (msg.message != WM_QUIT) { if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); } else { float dt = m_timer.DeltaSeconds(); // 上一帧到这一帧的时间,单位:秒 m_camera->Update(dt); // 先更新相机,再更新行星 m_solarSystem->Update(dt); RenderFrame(); // 真正的绘制调用都在这里 } }DeltaSeconds()返回的是秒而不是毫秒,这是个关键约定。有人把时间差直接当毫秒传进Update,行星会转得停不下来,这就是第5章要说的单位混用问题。帧循环的渲染顺序也有讲究:清屏必须在所有绘制之前,深度缓冲的Clear也不能省,否则上一帧的深度值会污染这一帧的遮挡关系。
渲染阶段大致是:清屏 → 绑定渲染目标和深度模板 → 设置视图/投影矩阵 → 遍历每颗行星设置世界矩阵、绑定纹理、绘制 → 解除绑定 → Present。这个顺序在代码里非常直观,之后调试黑屏时按这个顺序倒查,能很快定位问题。
3. 核心模块拆解:行星类、轨道计算与HLSL渲染
3.1 Planet类:用参数描述一颗行星
Planet类把所有天体的公共特质抽象成一组参数。这样做的直接好处是,新增一颗行星不需要改渲染逻辑,只需要加一个PlanetDesc实例。这是核心的类定义:
// Planet.h #include <DirectXMath.h> #include <wrl/client.h> struct PlanetDesc { float orbitRadius; // 公转轨道半径(场景单位) float orbitSpeed; // 公转速度(弧度/秒) float spinSpeed; // 自转速度(弧度/秒) float radius; // 行星半径(场景单位) const wchar_t* texturePath; // 表面纹理相对路径 }; class Planet { public: Planet(ID3D11Device* device, const PlanetDesc& desc); void Update(float deltaTime); void Render(ID3D11DeviceContext* context, const DirectX::XMMATRIX& viewProj); private: PlanetDesc m_desc; float m_orbitAngle = 0.0f; // 当前公转角度 float m_spinAngle = 0.0f; // 当前自转角度 DirectX::XMFLOAT4X4 m_world; // 每帧由Update计算 Microsoft::WRL::ComPtr<ID3D11Buffer> m_vertexBuffer; Microsoft::WRL::ComPtr<ID3D11Buffer> m_indexBuffer; Microsoft::WRL::ComPtr<ID3D11ShaderResourceView> m_textureSRV; UINT m_indexCount = 0; };构造函数里要做三件事:保存desc副本、把行星几何体生成到顶点/索引缓冲区、加载纹理创建SRV。这些资源创建只要成功一次,后面每帧渲染就是绑定与绘制的重复劳动。用ComPtr管理D3D对象,可以避免手动Release在异常路径上泄漏资源。
3.2 公转与自转的运动学计算
这是项目名称里“轨道自转公转”最核心的实现。Update函数的内容决定了行星每一帧的位置和朝向:
// Planet.cpp —— 行星更新逻辑 #include "Planet.h" #include <cmath> void Planet::Update(float deltaTime) { // 1. 角度累积:速度 × 时间 m_orbitAngle += m_desc.orbitSpeed * deltaTime; m_spinAngle += m_desc.spinSpeed * deltaTime; // 2. 归一化到 [0, 2π),避免长时间运行后浮点精度下降 if (m_orbitAngle >= DirectX::XM_2PI) m_orbitAngle -= DirectX::XM_2PI; if (m_spinAngle >= DirectX::XM_2PI) m_spinAngle -= DirectX::XM_2PI; // 3. 公转位置:圆周运动,轨道落在XZ平面 float x = cosf(m_orbitAngle) * m_desc.orbitRadius; float z = sinf(m_orbitAngle) * m_desc.orbitRadius; // 4. 世界矩阵:先缩放 → 自转 → 平移到轨道位置 DirectX::XMMATRIX scale = DirectX::XMMatrixScaling( m_desc.radius, m_desc.radius, m_desc.radius); DirectX::XMMATRIX rotation = DirectX::XMMatrixRotationY(m_spinAngle); DirectX::XMMATRIX translation = DirectX::XMMatrixTranslation(x, 0.0f, z); DirectX::XMStoreFloat4x4(&m_world, scale * rotation * translation); }逻辑分四步。第一步用速度乘时间累积角度,帧率不恒定时(比如拖动窗口)位置变化依然跟真实时间挂钩;第二步把角度减回0到2π范围,看起来无所谓,但程序挂机两小时后浮点误差会显现,cosf精度下降会导致轨道不再圆;第三步用三角函数把角度映射到XZ平面坐标,cos取X、sin取Z,从Y轴向下俯视时行星做逆时针公转;第四步的SRT顺序决定了“先缩放到实际大小,再自转,再放到轨道位置”。如果误把平移写在缩放之前,行星会以原点(太阳)为中心进行缩放,离太阳越远的球体越大,画面完全错误。
参数说明:orbitSpeed和spinSpeed的单位是弧度/秒。想模拟真实公转周期,传入2π / 周期秒数即可。项目里预设的数值不追求天文比例,因为真实比例下内行星紧贴太阳、外行星跑出视野,什么都看不见。视觉演示优先。
3.3 球体网格与纹理采样
行星的几何体不是模型文件导入的,而是在代码里程序化生成。常见做法是用经纬度网格把球面切分,再把每个四边形拆成两个三角形:
// Planet.cpp —— 生成球体顶点(节选) struct Vertex { DirectX::XMFLOAT3 pos; DirectX::XMFLOAT2 uv; }; std::vector<Vertex> vertices; const UINT latSegments = 32, lonSegments = 64; for (UINT lat = 0; lat <= latSegments; ++lat) { float v = (float)lat / latSegments; // 纬度比例 0~1 float phi = v * DirectX::XM_PI; // 纬度角 0~π for (UINT lon = 0; lon <= lonSegments; ++lon) { float u = (float)lon / lonSegments; // 经度比例 0~1 float theta = u * DirectX::XM_2PI; // 经度角 0~2π Vertex vert; vert.pos = DirectX::XMFLOAT3( sinf(phi) * cosf(theta), cosf(phi), sinf(phi) * sinf(theta) ); vert.uv = DirectX::XMFLOAT2(u, 1.0f - v); vertices.push_back(vert); } } // 索引按每个四边形两个三角形生成,此处省略sinf(phi)*cosf(theta)和sinf(phi)*sinf(theta)分别得到X和Z分量,cosf(phi)得到Y分量。生成的是单位球(半径=1),缩放交给世界矩阵完成。UV的v方向取1.0f - v,是因为D3D纹理坐标原点在左上角,而球体顶点是从北极开始生成的,不翻转的话纹理在行星上会上下颠倒。
3.4 着色器与绘制流程
行星的最终视觉效果由HLSL着色器决定。这里最关键的是常量缓冲的使用方式——视图投影矩阵是全局的,每帧只上传一次;世界矩阵是每颗行星独有的,在绘制每颗行星时单独上传。
// PlanetVS.hlsl —— 行星顶点着色器 cbuffer FrameConstants : register(b0) { float4x4 gViewProjection; }; cbuffer ObjectConstants : register(b1) { float4x4 gWorld; }; struct VIn { float3 position : POSITION; float2 uv : TEXCOORD; }; struct VOut { float4 position : SV_POSITION; float2 uv : TEXCOORD; }; VOut VS_main(VIn input) { VOut output; // 顶点顺序:模型 → 世界 → 视图 → 投影 float4 worldPos = mul(float4(input.position, 1.0f), gWorld); output.position = mul(worldPos, gViewProjection); output.uv = input.uv; return output; }mul(float4(input.position, 1.0f), gWorld)把局部坐标转成世界坐标,随后乘上gViewProjection变成裁剪空间坐标。HLSL的mul使用行主序矩阵时是向量在前,跟C++里XMMatrixMultiply的调用习惯要分清,否则矩阵顺序反了,行星会飞到奇怪位置。
// PlanetPS.hlsl —— 行星像素着色器 Texture2D gPlanetTex : register(t0); SamplerState gSampler : register(s0); struct PIn { float4 position : SV_POSITION; float2 uv : TEXCOORD; }; float4 PS_main(PIn input) : SV_TARGET { return gPlanetTex.Sample(gSampler, input.uv); }绘制时的调用顺序固定:VSSetShader绑定顶点着色器、PSSetShader绑定像素着色器、IASetInputLayout设置顶点布局、VSSetConstantBuffers上传常量和世界矩阵、PSSetShaderResources绑定纹理、最后DrawIndexed。这个顺序在代码里看起来重复,但每步对应管线的一个阶段,缺一步就画不出正确答案。
4. 运行、配置与窗口适配:把Demo调到顺滑状态
4.1 编译与运行步骤
拿到代码后我用VS打开解决方案,第一件事是确认Windows SDK版本。如果工程配置选的SDK版本与本地不一致,编译器会大片报错。操作路径是:项目属性 → 常规 → Windows SDK版本,选一个已安装的版本,再把配置管理器的平台设为x64或Win32,与工程原文匹配即可。
接下来确认DirectXTK的引用方式。如果工程通过NuGet引入DirectXTK,省事很多;如果是手动源码拷贝,要检查Include目录和Lib目录是否配对。程序里用到CreateWICTextureFromFile时,还需要链接DirectXTK.lib,并注意Debug/Release和x86/x64四种组合不能混搭。
一切就绪后直接F5。启动后如果VS输出窗口没有任何报错,画面中应该能看到发光的太阳和多颗行星在缓慢移动。操作方式围绕相机展开:WASD控制前后左右位移,鼠标右键拖拽旋转视角,滚轮拉近拉远。
4.2 行星参数调优:轨道半径与速度对照
真实比例的行星和轨道半径放进D3D是什么结果?太阳太亮、内行星太挤、外行星离得太远。所以项目里用的是视觉比例。我习惯把参数配成下面这样(数值供参考):
| 行星 | 轨道半径 | 行星半径 | 公转速度(弧度/秒) | 自转速度(弧度/秒) |
|---|---|---|---|---|
| 水星 | 12 | 0.4 | 0.9 | 1.0 |
| 金星 | 18 | 0.7 | 0.65 | 0.4 |
| 地球 | 24 | 0.8 | 0.5 | 1.2 |
| 火星 | 32 | 0.5 | 0.35 | 1.0 |
| 木星 | 48 | 2.0 | 0.22 | 1.8 |
| 土星 | 62 | 1.8 | 0.16 | 1.5 |
| 天王星 | 80 | 1.3 | 0.1 | 0.8 |
| 海王星 | 96 | 1.3 | 0.08 | 0.9 |
这套数值的要点是:内圈轨道半径差距小、外圈差距大,视觉上更像层层递散的盘面,而不是等间距圆环。公转速度递减,让每颗行星有明显不同的快慢节奏。如果想模拟金星逆行自转,把spinSpeed设为负值即可。
4.3 窗口适配与垂直同步
拖动窗口大小时,最容易翻车的不是布局,而是投影矩阵的宽高比没跟上。处理WM_SIZE消息时,要做两件事:更新Viewport的Width和Height,以及重新计算XMMatrixPerspectiveFovLH的AspectRatio。
另外注意垂直同步。IDXGISwapChain::Present(1, 0)表示锁60帧,Present(0, 0)表示不锁帧。这个太阳系项目建议开启垂直同步,否则低端显卡上旋转速度会变得极不稳定,轨道角速度看起来时快时慢。
5. 排查与避坑:五个让你怀疑人生的D3D时刻
5.1 定位问题的工具链
面对黑屏和显示异常,我一般按这个顺序排查。
先在创建设备时加D3D11_CREATE_DEVICE_DEBUG标志,让D3D调试层在错误发生处输出详细原因。这个标志打开后,输出窗口会打印错误到哪一步,以及涉及哪个API调用,比对着代码干瞪眼快得多。
再用VS的图形诊断:开始诊断后运行程序、切换一帧,可以在Graphics栏里看到该帧的绘制调用列表、每个阶段绑定的资源和常量缓冲内容。行星消失时,第一步先看DrawIndexed有没有被调用,再看绑定到相同槽位的常量缓冲是不是某个行星的世界矩阵。
最后是朴素的日志法。在每帧的关键步骤输出HRESULT和资源状态。文件加载失败时会得到0x80070002(文件不存在)、0x80070003(路径找不到)这类错误码,先解决文件问题再谈渲染。
5.2 五条踩坑记录(现象→原因→解决)
问题1:行星显示为纯色,纹理加载无效
现象:球体形状正常,但表面是白色或黑色,看不到贴图。 原因:CreateDDSTextureFromFile只认DDS格式,把JPG直接传进去会失败,失败后的SRV是空指针,采样结果为默认白色。 解决:改用CreateWICTextureFromFile加载常见图片格式,并确认textures目录在exe的工作目录下。文件找不到时先打印HRESULT,看到0x80070002先查路径。
问题2:遮挡关系错乱,远处的行星透穿过来
现象:位置判断没问题,但后方行星叠到前方行星上,场景没有层次。 原因:只创建了渲染目标视图,没创建深度模板缓冲。没有深度测试,GPU按提交顺序绘制,后画的直接盖住先画的。 解决:创建深度模板纹理和DepthStencilView,每帧开始时ClearDepthStencilView,并在OMSetRenderTargets调用中把深度视图绑定到第二个参数槽位。
问题3:行星自转方向看起来反了
现象:同一颗行星,纹理亮斑移动方向和公转方向相反。 原因:D3D是左手坐标系,绕Y轴正方向旋转在俯视视角下是顺时针,跟右手坐标系的习惯相反。 解决:自转速度统一设正值,想逆行就把公转速度的方向反过来,而不是在自转里取负号。
问题4:启动即黑屏,找不到任何行星
现象:程序无报错,窗口全黑,怎么转相机都是黑屏。 原因:相机初始位置在场景中心,或lookAt方向没对准太阳;另一种可能是光栅化状态里的背面剔除把球体正面剔掉了。 解决:先把相机初始化为从斜上方看向原点,例如eye=(30, 25, 60)、lookAt=(0,0,0),再把CullMode临时设为D3D11_CULL_NONE验证球体本身。
问题5:窗口尺寸变化后画面拉伸变形
现象:拖动窗口后行星全变成椭圆,比例失调。 原因:视口和投影矩阵的宽高比还停留在创建时的值,没有跟着窗口尺寸更新。 解决:在WM_SIZE里同步更新Viewport和XMMatrixPerspectiveFovLH的AspectRatio。把这两步封装成OnResize(),在初始化末尾和WM_SIZE里各调用一次。
6. 二次开发:加天体、改轨道与做行星跟随
想把这套D3D太阳系改成自己的演示,核心工作量几乎都在数据和少量Update逻辑上,不碰渲染管线。
6.1 快速添加一颗新天体
在SolarSystem的初始化列表里push一个Planet实例就能完成:
// SolarSystem.cpp —— 增加自定义天体 PlanetDesc comet = {}; comet.orbitRadius = 75.0f; // 插在木星和土星之间 comet.orbitSpeed = 0.12f; comet.spinSpeed = 0.5f; comet.radius = 0.5f; comet.texturePath = L"textures/comet.jpg"; m_planets.push_back(std::make_unique<Planet>(m_device, comet));PlanetDesc是值语义,传入Planet构造后内部持有副本,后续修改外部变量不影响已创建实例。新增的彗星会自动参与公转、自转和渲染,不需要改任何别的代码。
6.2 椭圆轨道与倾斜轨道
把圆形轨道改成椭圆,只需替换Update里的位置公式:
float x = semiMajor * cosf(m_orbitAngle); // 半长轴 float z = semiMinor * sinf(m_orbitAngle); // 半短轴再给轨道加倾角时,在translation之前乘以旋转矩阵即可:
DirectX::XMMATRIX orbitTilt = DirectX::XMMatrixRotationX(0.3f); DirectX::XMMATRIX translation = DirectX::XMMatrixTranslation(x, 0.0f, z); DirectX::XMMATRIX world = scale * rotation * orbitTilt * translation;0.3f弧度约等于17度,轨道会稍微倾斜,行星运行轨迹就不再局限在同一个平面内。
6.3 行星跟随与轨道线绘制
交互上我建议优先补两个能力。行星跟随:在Camera中维护一个目标位置,每帧把eye设为行星位置加固定偏移量,lookAt设为行星位置,就能获得“站在肩头看一颗行星”的视角切换效果。轨道线绘制:用D3D11_PRIMITIVE_TOPOLOGY_LINELIST生成一圈(cosθ*R, 0, sinθ*R)顶点,每帧绑定单位矩阵绘制,观众就能直观看到每颗行星的轨道半径差,演示效果提升明显。
有一次我为了给土星加环,折腾了大半夜,最后发现是环的两面都没有正确朝向相机,阴影全部投在了背面。从那以后,我每次在D3D里加新的天体部件,都会强制把坐标系的约定写进注释第一行,并用一个简单的替代体先验证朝向再上正式模型。做D3D这种底层项目,细节真是一人一个坑,希望这篇拆解能帮你起步时少踩几个。
本文还有配套的精品资源,点击获取