简介这是一份基于Direct3D 9.0与Visual C 10开发的太阳系三维模拟工程完整呈现了行星轨道、自转与公转等天体运动并通过摄像机交互、纹理贴图与天空盒营造出沉浸式宇宙场景适合学习3D图形编程、游戏开发及科学可视化的开发者阅读。压缩包为RAR格式共109个文件、约14.2MB主要包含C源码.cpp/.h、Visual Studio工程配置.sln/.vcxproj、编译后的可执行文件与中间文件.exe/.obj/.pdb以及大量运行截图.jpg目录结构清晰便于按代码模块对照分析。已有546人学习下载。项目在实现上覆盖了D3D初始化、摄像机动态控制、球体绘制、纹理映射、天空盒渲染等关键环节同时结合椭圆轨道与开普勒定律模拟行星公转自转周期也按物理规律设置既可运行可执行文件直接观察效果也可从源码逐模块学习三维场景搭建与动画控制是理解D3D9.0图形管线的实用案例。1. 为什么把太阳系搬进 D3D一次能跑完整个渲染管线的“毕业设计级”练手很多人第一次听到“D3D太阳系轨道自转公转”会觉得这是教学 Demo 里的花瓶一堆球绕着球转难度都在美术上。但真正拿 Direct3D 动手做过的人会告诉你这个项目比想象中更能暴露问题——它要求你同时处理矩阵变换顺序、GPU 资源生命周期、深度精度和帧率抖动恰好覆盖了从顶点进入 GPU 到像素上屏的全部关键路径。它并不是用 Unity 拖几个球体再挂脚本那么简单而是逼你把视角矩阵、光栅化、常量缓冲、设备丢失这类底层概念全部实现一遍。这套方案的典型适用人群有三种正在学 D3D11/D3D12 想用完整项目验证学习的图形学初学者、需要给作品集凑一个能讲清楚“自转和公转矩阵怎么组合”的在校生以及想从引擎封装里跳出来、重新理解相机和变换流水线的在职开发。它不需要加载外部模型也不需要做碰撞和物理全部代码量控制在千行出头但每个文件都有存在价值。本文会从数学、渲染、设备管理和排错四个层面把这个项目讲透并把最容易被忽略的“GPU 崩溃或 D3D 设备已移除”这类问题单独拎出来。2. 轨道自转公转的数学矩阵组合顺序定生死2.1 三个角度公转、自转、轨道倾角太阳系里每个天体只有三种独立运动绕太阳的公转、绕自己轴的自转、以及轨道平面相对“黄道面”的倾斜。公转可以近似成圆轨道重点是它的周期和半径自转则在球面网格的局部空间里发生轨道倾角让整条轨道所在的平面绕某个轴倾斜。三者一旦组合错顺序行星就会边自转边绕到太阳背后或者轨道平面全面错乱。以地球为例公转位置可以用两个参数描述轨道半径 R 和当前角度。每帧让公转角度按时间步长累加然后把角度换算成太阳系坐标系里的位置// angleOrbit 和 angleSelf 均为 double避免长时间运行累积误差 double angleOrbit 2.0 * PI * (t / periodOrbit); double angleSelf 2.0 * PI * (t / periodSelf); float x static_castfloat(R * cos(angleOrbit)); float z static_castfloat(R * sin(angleOrbit));这段代码里periodOrbit是公转周期的现实值对应的虚拟秒数。比如把地球的公转周期设成 60 秒火星设成约 112.8 秒那么 t 每增加 1代表现实中大约 6 天。角度换算成弧度制避免出现 360° 周期还需要在代码里写度数转换的麻烦。自转不需要改变位置只需要让模型空间的顶点绕 Y 轴旋转。关键在矩阵组合。DirectXMath 里一个行星的世界矩阵通常是XMMATRIX Rself XMMatrixRotationY(angleSelf); // 自转 XMMATRIX T XMMatrixTranslation(x, 0.0f, z); // 公转位置 XMMATRIX W XMMatrixMultiply(Rself, T); // 先自转再平移到轨道位置 XMMATRIX orbitX XMMatrixRotationX(tiltAngle); // 整条轨道倾斜 W XMMatrixMultiply(W, orbitX);这里的XMMatrixMultiply(Rself, T)是数学矩阵乘法含义是先把顶点做自转再将结果平移到太阳系坐标里的轨道位置。orbitX放在最后表示整条轨道所在的 XZ 平面绕 X 轴倾斜了一个角度。如果你写反成XMMatrixMultiply(T, Rself)结果是先把顶点平移到轨道位置再绕 Y 轴自转看起来行星就在原地“翻跟头”而不是边公转边自转。这是太阳系 Demo 里最容易犯、又最难用肉眼定位的错误。2.2 常量缓冲设计一帧里最少提交什么D3D11 里要把矩阵传给着色器最常用的手段是常量缓冲。一个行星一帧只需要更新一份数据里面包含世界矩阵、世界矩阵逆转置、复合后的世界-观察-投影矩阵以及基础颜色和光照方向。复合矩阵尽量在 CPU 侧算好不要让 GPU 每个顶点重复做矩阵乘法struct cbPerObject { XMFLOAT4X4 gWorld; XMFLOAT4X4 gWorldInvTranspose; XMFLOAT4X4 gWorldViewProj; XMFLOAT4 gAlbedo; XMFLOAT3 gLightDir; };给 GPU 传输前要把矩阵转置成行主序存进常量缓冲否则 HLSL 里的mul(float4(pos, 1), gWorldViewProj)会得到转置后的错误结果。记住一个稳定规则DirectXMath 里的XMFLOAT4X4按行存储而 HLSL 默认按列读取你统一使用 CPU 侧XMMatrixTranspose转储一遍HLSL 用行向量乘矩阵两边就一致了。法线矩阵是另一个容易忽略的点。世界矩阵包含旋转和平移平移部分不影响法线方向但旋转必须转回。数学上正确的变换是逆矩阵的转置。因为这里的变换只有旋转和平移没有非均匀缩放直接用世界矩阵的旋转部分也能算但工程上为了以后扩展成椭球行星我习惯把逆转置矩阵提前算好放进去XMMATRIX W XMMatrixMultiply(Rself, T); XMMATRIX invW XMMatrixInverse(nullptr, W); XMMATRIX invTranspose XMMatrixTranspose(invW);XMMatrixInverse的第二个参数需要传入一个特征向量用于求逆失败时的退化处理这里传nullptr表示不需要该项。把invTranspose之后直接存入常量缓冲HLSL 里用mul(normal, gWorldInvTranspose)就能得到正确世界法线。每颗行星的 Albedo 不同太阳给成高亮橙黄、地球蓝色、火星暗红这样不需要纹理就能在屏幕上区分天体。3. 渲染一条完整链路网格生成、顶点缓冲与 HLSL 里的世界变换3.1 用细分球而不是加载模型参数由算法直接控制太阳系里所有行星都是球体去加载 FBX 反而多余。运行时生成一个经纬度细分球可以自由控制顶点密度还能保证法线和位置完全对齐。常见的生成参数是slices经度切分数和stacks纬度切分数太阳用 64×64其他行星用 32×32 足够。struct Vertex { DirectX::XMFLOAT3 pos; DirectX::XMFLOAT3 normal; DirectX::XMFLOAT2 uv; }; const int slices 32, stacks 16; const float radius 0.5f; std::vectorVertex vertices((stacks 1) * (slices 1)); for (int s 0; s stacks; s) { float phi DirectX::XM_PI * s / stacks; for (int l 0; l slices; l) { float theta 2.0f * DirectX::XM_PI * l / slices; float y radius * cosf(phi); float r radius * sinf(phi); int idx s * (slices 1) l; DirectX::XMFLOAT3 pos(r * cosf(theta), y, r * sinf(theta)); vertices[idx].pos pos; DirectX::XMStoreFloat3(vertices[idx].normal, DirectX::XMVector3Normalize(XMLoadFloat3(pos))); vertices[idx].uv XMFLOAT2((float)l / slices, (float)s / stacks); } }这段代码的几何逻辑是纬度方向把球从北极到南极切成stacks层经度方向每层均匀分布slices个点。法线直接取位置向量归一化因为单位球上任一点的径向方向就是法线。UV 用于将来贴纹理现在可以先放着。细分参数直接影响两件事顶点数量以及球体边缘在有光照时的平滑程度。stacks太低小于 8站在近处能看到棱角太高大于 256生成和绘制开销都不大但对这种教学项目纯属浪费。32×16 在 1080p 下已完全平滑。索引缓冲区按两个三角形拼一个四边形来排每个四边形由上下两层相邻顶点构成。D3D11 的三角形缠绕方向默认是顺时针正面生成时要保证三角形法线朝外否则光照会反std::vectoruint32_t indices; for (int s 0; s stacks; s) { for (int l 0; l slices; l) { int a s * (slices 1) l; int b a 1; int c (s 1) * (slices 1) l; int d c 1; indices.push_back(a); indices.push_back(c); indices.push_back(b); // 三角形 1 indices.push_back(c); indices.push_back(d); indices.push_back(b); // 三角形 2 } }随后把vertices和indices分别填进D3D11_BUFFER_DESC和D3D11_SUBRESOURCE_DATA调用ID3D11Device::CreateBuffer生成顶点缓冲与索引缓冲。需要注意的是这一步放在初始化阶段只做一次绝不能放进帧循环。3.2 顶点着色器和像素着色器最低配光照怎么写出质感只有纯色球的太阳系很像 PPT 图表加一个简单的 Lambert 漫反射光照就能让球体有立体感。光照方向在 CPU 侧作为常量传入模拟太阳方向来光顶点着色器负责把模型空间顶点和法线变换到世界空间像素着色器负责做光照计算。cbuffer cbPerObject : register(b0) { float4x4 gWorld; float4x4 gWorldInvTranspose; float4x4 gWorldViewProj; float4 gAlbedo; float3 gLightDir; }; struct VSIn { float3 posL : POSITION; float3 normalL : NORMAL; float2 uv : TEXCOORD0; }; struct VSOut { float4 posH : SV_POSITION; float3 normalW : NORMAL; float3 worldPos : TEXCOORD0; }; VSOut VS(VSIn vin) { VSOut vout; vout.posH mul(float4(vin.posL, 1.0f), gWorldViewProj); vout.normalW mul(float4(vin.normalL, 0.0f), gWorldInvTranspose).xyz; vout.worldPos mul(float4(vin.posL, 1.0f), gWorld).xyz; return vout; } float4 PS(VSOut pin) : SV_TARGET { float3 n normalize(pin.normalW); float3 l normalize(-gLightDir); float diff saturate(dot(n, l)); return float4(gAlbedo.rgb * diff 0.05f * gAlbedo.rgb, 1.0f); }设gLightDir为从太阳指向行星的方向也就等于平行光方向。代码里取负号是因为 Lambert 光照需要片元指向光源的向量。世界空间坐标虽然没有直接在像素着色器里使用但留着可以后续加距离衰减和雾效。像素着色器里0.05f * gAlbedo.rgb是环境光项模拟太空里微弱漫反射防止行星背面全黑。这套着色器不带纹理所以自转很难看出来。第二个坑的解法是给每个行星生成一张简单的 CPU 噪点纹理或者直接在着色器里用 UV 画几条纬度线。最省事的做法是修改像素着色器if (frac(pin.uv.y * 8.0f) 0.05f) return float4(0.0f, 0.0f, 0.0f, 1.0f);让球体表面出现几条暗纹自转立刻可观察。4. D3D 设备生命周期交换链、后备缓冲与“设备已移除”4.1 D3D11 里四层对象各管什么D3D11 的设备对象体系比 OpenGL 直观ID3D11Device管资源创建和状态对象ID3D11DeviceContext管绘制指令IDXGISwapChain管前后台缓冲的交换而ID3D11RenderTargetView和ID3D11DepthStencilView是后备缓冲的“视图”入口。太阳系项目只需要一个渲染目标和一个深度模板缓冲初始化顺序是固定的。创建流程一般是先调D3D11CreateDeviceAndSwapChain同时拿到设备和交换链再从交换链拿后台缓冲纹理创建 RTV然后创建同等尺寸的深度纹理和 DSV最后设置视口。交换链描述里的BufferCount设成 2DXGI_FORMAT_R8G8B8A8_UNORM用作颜色格式DXGI_SWAP_EFFECT_DISCARD是老式却最稳的交换模式SampleDesc.Count必须为 1 以关闭多重采样。深度缓冲经常被随手创建成和后备缓冲一样的大小这没有问题真正的问题是初始状态和每帧清理。创建深度纹理时D3D11_USAGE_DEFAULT即可不要设置成DYNAMIC因为深度纹理不需要 CPU 回读。每帧先ClearRenderTargetView和ClearDepthStencilView再开始画否则上一帧的深度数据会让本轮绘制出现随机遮挡。4.2 帧循环里不能出现的分配动作帧循环的粗糙写法往往能跑通 30 秒然后突然黑屏或弹“GPU 发生崩溃或 D3D 设备已移除”。最常见原因不是逻辑错误而是把资源创建写进了循环体。比如有人为了让行星颜色渐变每帧调用一次CreateTexture2D或者为了调整细分每帧重建顶点缓冲。这些调用在初始化时没问题放进帧循环就等于每 16 毫秒给 GPU 队列压一次资源分配请求驱动缓冲区和显存都会被迅速打满最终触发看门狗。合理的帧循环只有四类操作更新时间、更新常量缓冲、绘制、Present。static float clearColor[4] {0.003f, 0.003f, 0.005f, 1.0f}; ctx-ClearRenderTargetView(rtv, clearColor); ctx-ClearDepthStencilView(dsv, D3D11_CLEAR_DEPTH | D3D11_CLEAR_STENCIL, 1.0f, 0); UpdateOrbit(dt); // 更新各行星角度 for (PlaneData p : planets) { FillConstantBuffer(p); // 写 CPU 侧临时结构体 ctx-UpdateSubresource(cbPerObject, 0, nullptr, p.constData, 0, 0); ctx-VSSetConstantBuffers(0, 1, cbPerObject); ctx-PSSetConstantBuffers(0, 1, cbPerObject); ctx-DrawIndexed(p.indexCount, 0, 0); } HRESULT hr swapChain-Present(1, 0);UpdateSubresource是同步更新它会立刻把 CPU 里的数据复制到 GPU 对应缓冲资源中适合这种小体量常量缓冲。如果数据量更大用Map(D3D11_MAP_WRITE_DISCARD)后memcpy再Unmap能避免卡住流水线但太阳系项目用不到。Present(1, 0)的第一个参数 1 表示开启垂直同步把帧率锁到显示器刷新率避免自转动画闪成高速转圈。这段流程里没有出现任何 Create 调用。如果你监控到每帧的创建次数是 0设备丢失概率已经降掉一大半。剩下一半来自着色器死循环、索引超出缓冲范围以及显存超限。这些都是下一章的排错重点。5. D3D 太阳系避坑清单设备丢失、轨道漂移、闪烁与“看不出自转”5.1 设备移除与 Unreal 的 LowLevelFatalError靠 TDR 排查而不是重启现象程序运行几十秒到几分钟后窗口黑掉或者系统弹“显示驱动已停止响应并已恢复”。如果你把 D3D 渲染逻辑导出到 Unreal 做过二次开发还会看到 “LowLevelFatalError: Unreal Engine is exiting due to D3D device being lost” 这种崩溃日志名字带 Unreal根因却和引擎无关是 Windows 图形驱动把 D3D 设备判定为已移除。原因Windows 的看门狗机制会监控 GPU 命令执行时间。默认情况下如果 GPU 在 2 秒内没有完成提交的命令驱动就打一个 TDR 重置点强制恢复设备。太阳系这种低负载场景触发 TDR几乎都是从帧循环里创建资源、顶点缓冲尺寸错误、像素着色器写死循环这几类中招。Unreal 工程里还有常见的一种是显存分配过多比如纹理尺寸用int溢出的负数被当作无符号大数。解决打开 D3D11 Debug Layer让运行时在第一个非法调用处就报错而不是让 GPU 跑到一半崩了才被 TDR 发现。uint32_t flags 0; #ifdef _DEBUG flags | D3D11_CREATE_DEVICE_DEBUG; #endif HRESULT hr D3D11CreateDeviceAndSwapChain(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, flags, nullptr, 0, D3D11_SDK_VERSION, scDesc, swapChain, device, nullptr, ctx);Debug Layer 会在调试输出窗口打出自定义错误消息标明调用的 API 名和参数。比如DrawIndexed的索引数量超过索引缓冲实际元素个数Debug Layer 的报错会在 Present 时出现。看到这种系统级排错信息后再检查设备移除的返回码swapChain-Present返回DXGI_ERROR_DEVICE_REMOVED或DXGI_ERROR_DEVICE_RESET时调device-GetDeviceRemovedReason()判断具体原因是DXGI_ERROR_DEVICE_HUNG还是DXGI_ERROR_DEVICE_REMOVED。5.2 轨道漂移float 累计角度的舍入误差现象公转刚启动时轨道很圆跑三五分钟后行星位置出现肉眼可见的偏移最后甚至螺旋状飘向远处。这颗行星如果长期运行会彻底脱离太阳系。原因单精度 float 只有 7 位有效十进制数字角度累加越积越小。太阳系尺度很大地球轨道半径如果用卡西尼单位可能是 1.5 亿公里量级单个 float 在表示这个大数时能分辨的最小步长已经是数百米再把角度误差放进去行星位置自然偏离。处理办法是不要用 float 累加长时间变量。解决时间 t 和角度用 double 存储渲染前再转 float同时每次计算位置前对时间取模归一化。double t 0.0; // 全局双精度时间 void UpdateOrbit(double dt) { t dt; double alpha fmod(t / periodOrbit, 1.0) * 2.0 * PI; float x static_castfloat(R * cos(alpha)); float z static_castfloat(R * sin(alpha)); }fmod(t / periodOrbit, 1.0)取出当前周期内的比例再乘 2π保证角度始终落在 0~2π 区间理论上可以连续运行数天都不漂移。这套改动很小但对长期演示项目是必须的。5.3 深度精度Near/Far 比值过大导致闪烁现象转头看向太阳时远处火星的表面网格线不停闪烁靠近地球的部分正常。如果场景里有半透明光晕闪烁还会加剧。原因D3D11 默认深度缓冲是非线性的近处的精度远高于远处。投影矩阵里near和far的比值超过 1000 后远处深度值只有少数几个离散档位两个深度差很小的片元就会随机胜出表现成闪烁。太阳系距离跨度太大地球轨道半径和冥王星轨道半径差了两个数量级再加太阳内部半径场景深度跨度极为夸张。解决最实用的是先归一化坐标。太阳系场景的世界空间不要用公里或天文单位把最大轨道半径缩到 10 以内行星半径缩到 0.5 以内这样 near 设 0.1、far 设 100 就能覆盖全部。投影矩阵关系为z_ndc far / (far - near) - far * near / ((far - near) * z_eye)near0.1、far100 时远处深度值仍有大约 0.99 的分辨率闪烁问题消失。5.4 球体自转看不出几何对称带来的视觉掩盖现象地球明明在转但屏幕上完全看不出变化像冻结了。原因均匀球体在所有旋转下都呈现相同的几何外形没有纹理或颜色差异时观察者无法感知自转。这在图形学里是一个常见陷阱。解决给球体贴一条纬线带。在像素着色器里加一行基于 UV 的判断效果立竿见影float stripe smoothstep(0.46f, 0.50f, abs(pin.uv.y * 8.0f - 0.5f));uv.y乘以 8 再取小数部分产生周期性条纹smoothstep把条纹边缘柔化一道。把结果乘到 Albedo 上自转时条纹会明显平移视觉反馈立刻建立。对每颗行星用不同的条纹数量还能解决“太阳和地球长得一样”的辨识度问题。5.5 帧率依赖为什么窗口拖动会让行星突然加速现象把程序最大化或拖动窗口时帧率从 60 掉到 30行星公转速度肉眼可见变慢把 Present 的垂直同步关掉后帧率到了 300行星又快速疯转。原因代码直接用每帧的deltaTime更新角度或干脆每帧固定累加 0.01两者都会让运动速度和真实时间脱钩。固定累加等于帧率越高跑得越快只用 deltaTime 则受垂直同步波动影响。解决引入固定时间步长累加器把物理更新和渲染帧率解耦。详见下一章。6. 让公转节奏稳定的固定时间步长和一眼看出周期问题的验证习惯6.1 固定时间步长的实现累加器写法物理更新步长固定为 1/120 秒渲染帧率仍旧跟着显示器走两者不再互相干扰。逻辑是先把真实流逝时间累加进 accumulator再按步长一次性消费掉const double step 1.0 / 120.0; double accumulator 0.0; double lastTime timer.GetSeconds(); while (running) { double now timer.GetSeconds(); double frame now - lastTime; lastTime now; if (frame 0.25) frame 0.25; // 防止从后台恢复时一次性补大量步长 accumulator frame; while (accumulator step) { UpdateOrbit(step); accumulator - step; } Render(); }step选 1/120 是为了在量级上高于显示器刷新率保证公转动画的平滑度1/600 更精细应用于需要高速 z 轴旋转的太阳表面。注意加了一个 0.25 的钳制如果程序卡顿超过 250 毫秒说明已经发生丢帧不再盲目补齐物理步长否则玩家会看到行星瞬间瞬移。6.2 用轨道周期表验证你的实现太阳系项目最容易出现的问题不是渲染差而是轨道速度参数填错。验证方法不在渲染结果里肉眼看而是把每颗行星的理论周期写成一个常量表在控制台每隔一段时间打印角度差printf(t%8.3f earth%10.4f mars%10.4f jupiter%10.4f\n, t_seconds, fmod(T / 60.0, 1.0), fmod(T / 112.8, 1.0), fmod(T / 711.6, 1.0));观察mars角度增量大约应是earth的一半因为火星周期约是地球的 1.88 倍。如果打印出的比值和时间不符大概率是UpdateOrbit里的周期单位与步长不统一。另一个技巧是按虚拟秒缩放所有周期把地球公转周期设为 60 秒、火星 112.8 秒这样演示时能在半分钟内看到地球转了一圈而木星只转过约 30°视觉效果远比完全按真实时间更容易让人看出轨道差异。做这类 D3D 演示项目我现在已经习惯先把周期常量表和双精度时间写在最前面而不是等渲染出来再回头调参数。轨道速度的核对比光照效果重要得多。希望帮到你。本文还有配套的精品资源点击获取