窗帘上的 MV :从静态布料到动态视频材质

上一篇“百万/千万粒子性能实测”( https://www.cnblogs.com/xingrenh/p/19660717 ),已经解释了为什么 OpenTK 的原生路径能把性能推到非常高的量级; 基于这个高性能底座,本篇做出 静态图片布料 和 视频材质布料 的视觉递进效果
- 从性能基线到“可塑形材质”
我们已经从前面的我文章看到 “CPU 控制 + GPU 批量渲染” 这条路线的性能上限非常高,既然点粒子能做到百万级稳定,甚至千万级仍然能跑,那么把“独立粒子”变成“有连接关系的粒子网络”应该也是可行的。
渐进尝试
粒子彼此独立,只追求吞吐。
粒子之间加入约束,追求“连续形变”。
在布料基础上,把静态贴图换成动态视频帧,追求“时间维度美感”。
- 静态图片布料的核心原理
2.1 质点-弹簧模型:形态来自“约束”
布料网格中的每个点都是一个粒子,点与点之间通过结构弹簧、剪切弹簧、弯曲弹簧耦合。 每一帧先做外力积分,再多轮约束修正。最终我们看到的“柔顺、下垂、抖动”并不是特效,而是约束系统求解后的自然结果。

核心代码:
// OpenTKClothForm.cs
private void SatisfyConstraints()
{
for (int row = 0; row < ClothRows; row++)
{
for (int col = 0; col < ClothCols; col++)
{
int i = row * ClothCols + col;
if (col < ClothCols - 1)
ApplyConstraint(i, i + 1, Spacing, paramStructural); // 结构
if (row < ClothRows - 1)
ApplyConstraint(i, i + ClothCols, Spacing, paramStructural); // 结构
}
}
for (int row = 0; row < ClothRows - 1; row++)
{
for (int col = 0; col < ClothCols - 1; col++)
{
int i = row * ClothCols + col;
ApplyConstraint(i, i + ClothCols + 1, DiagSpacing, paramStructural * 0.8f); // 剪切
ApplyConstraint(i + 1, i + ClothCols, DiagSpacing, paramStructural * 0.8f); // 剪切
}
}
for (int row = 0; row < ClothRows; row++)
{
for (int col = 0; col < ClothCols; col++)
{
int i = row * ClothCols + col;
if (col < ClothCols - 2)
ApplyConstraint(i, i + 2, Spacing * 2f, paramBend); // 弯曲
if (row < ClothRows - 2)
ApplyConstraint(i, i + ClothCols * 2, Spacing * 2f, paramBend); // 弯曲
}
}
}
// OpenTKClothForm.cs
private void ApplyConstraint(int a, int b, float restLen, float stiffness)
{
int ai = a * 3, bi = b * 3;
float dx = positions[bi] - positions[ai];
float dy = positions[bi + 1] - positions[ai + 1];
float dz = positions[bi + 2] - positions[ai + 2];
float dist = MathF.Sqrt(dx * dx + dy * dy + dz * dz);
if (dist < 1e-6f) return;
float correction = (dist - restLen) / dist * 0.5f * stiffness;
float cx = dx * correction;
float cy = dy * correction;
float cz = dz * correction;
bool pa = pinned[a] || a == draggedNode;
bool pb = pinned[b] || b == draggedNode;
if (pa && pb) return;
if (!pa && !pb)
{
positions[ai] += cx; positions[ai + 1] += cy; positions[ai + 2] += cz;
positions[bi] -= cx; positions[bi + 1] -= cy; positions[bi + 2] -= cz;
}
else if (pa)
{
positions[bi] -= cx * 2; positions[bi + 1] -= cy * 2; positions[bi + 2] -= cz * 2;
}
else
{
positions[ai] += cx * 2; positions[ai + 1] += cy * 2; positions[ai + 2] += cz * 2;
}
}
2.2 鼠标交互:把“输入”映射到物理世界
为了让画面更有生命力,我把鼠标移动转换到世界空间的位移增量,直接作用在被抓取粒子上。 这让“点击/拖拽”的视觉反馈从 UI 交互升级成了“真实扰动”。

好像一片流动的丝绸。
核心代码:
// OpenTKClothForm.cs
private void GlControl_MouseMove(object sender, MouseEventArgs e)
{
if (draggedNode >= 0)
{
int dx = e.X - lastMousePos.X;
int dy = e.Y - lastMousePos.Y;
lastMousePos = e.Location;
Vector3 delta = ScreenToWorldDelta(dx, dy);
int pi = draggedNode * 3;
positions[pi] += delta.X;
positions[pi + 1] += delta.Y;
positions[pi + 2] += delta.Z;
prevPositions[pi] = positions[pi];
prevPositions[pi + 1] = positions[pi + 1];
prevPositions[pi + 2] = positions[pi + 2];
glControl.Invalidate();
}
}
- 把材质“点亮时间维度”
该处理的核心不是重新做一套布料,而是复用上面的物理框架,只替换材质来源: 从单张图片改成连续视频帧。于是布料不再只是被风吹动,而是像“正在播放的幕布电影”。

3.1 预处理:先抽帧,再全量解码
真实的流畅来自“播放阶段不做重活”,务必确保最高性能,和高度流畅感,但凡有任何的卡顿,都会影响“高级感”。我们把 ffmpeg 与解码成本前置,在准备阶段一次性完成。 播放时只做内存读取 + 纹理上传,避免实时解码造成抖动。
核心代码:
// OpenTKVideoClothForm.cs
private void PrepareVideoFramesWithFfmpeg()
{
string ffmpegPath = Path.Combine(mediaRoot, "ffmpeg.exe");
string demoVideoPath = Path.Combine(mediaRoot, "Demo.mp4");
string outputPattern = Path.Combine(framesOutputDirectory, "frame_%06d.png");
string arguments =
$"-y -i \"{demoVideoPath}\" -vf \"fps={VideoFrameRate},scale={VideoScaleWidth}:-1:flags=lanczos\" \"{outputPattern}\"";
using var proc = new Process { StartInfo = new ProcessStartInfo {
FileName = ffmpegPath, Arguments = arguments, UseShellExecute = false, CreateNoWindow = true
}};
proc.Start();
proc.WaitForExit();
if (proc.ExitCode != 0) throw new Exception($"ffmpeg 执行失败,退出码: {proc.ExitCode}");
videoFrameFiles = Directory.GetFiles(framesOutputDirectory, "frame_*.png")
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToArray();
frameCache.Clear();
DecodeAllFramesToMemory(); // 关键:全量预解码
UploadFrameToTexture(0);
}
// OpenTKVideoClothForm.cs
private void DecodeAllFramesToMemory()
{
int total = videoFrameFiles.Length;
for (int i = 0; i < total; i++)
{
var pixels = DecodeFrame(videoFrameFiles[i]);
frameCache[i] = pixels;
int percent = (int)((i + 1) * 100.0 / total);
prgPrepare.Value = Math.Clamp(percent, 0, 100);
lblPrepareStatus.Text = $"准备状态:解码中 {percent}% ({i + 1}/{total})";
Application.DoEvents();
}
}
private FramePixels GetFramePixels(int frameIndex)
{
if (frameCache.TryGetValue(frameIndex, out var cached))
return cached;
throw new InvalidOperationException($"视频帧 {frameIndex} 尚未预解码完成。");
}
3.2 FPS:60
确保高帧率的关键点:
避免运行时解码:把昂贵的 I/O 和图片解码提前到准备阶段。
固定步进更新:视频帧推进按固定时间间隔,减少“忽快忽慢”的主观卡顿。
双纹理上传:渲染采样与下一帧上传分离,降低 GPU 同步等待概率。
-
后续可扩展研究
加入法线贴图/粗糙度贴图,做更真实的面料差异。
从 CPU 约束求解迁移到 Compute Shader,进一步提高网格密度。
引入角色碰撞体,让布料与人体动作联动,走向虚拟试衣。 -
联想
这让我想起乡下的夜晚,时而播放的幕布电影,一般最常播放这种地道战打鬼子系列:

不过我们做成数字版的MV短片,也非常的漂亮,就好像挂起的窗帘,被风徐徐吹动,有种很特别的意境:


浙公网安备 33010602011771号