Skip to content

Commit 07f81ee

Browse files
zlyfunctionclaude
andcommitted
Add IPC simulation demo: GPU-precomputed, browser playback
Two Incremental Potential Contact scenes simulated offline with libuipc (built from source for sm_75) and replayed in a three.js viewer: soft bodies stacking/squishing, and elastic rods draping over a sphere. Baked as Int16 + frame-delta + gzip (2.4MB total). Generation scripts included under ipc_demo/gen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent df97885 commit 07f81ee

15 files changed

Lines changed: 1073 additions & 0 deletions

File tree

index.html

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,10 @@
967967
background: #fef7e0;
968968
}
969969

970+
.misc-card-icon--ipc {
971+
background: #ede7f6;
972+
}
973+
970974
.misc-card-title {
971975
font-family: var(--font-display);
972976
font-size: 1.0625rem;
@@ -1906,6 +1910,25 @@ <h2 class="section-title" data-en="Miscellaneous" data-zh="其他">Miscellaneous
19061910
data-zh="戳它、割它、对它吹风 —— 一块任你欺负的布。">Poke it, cut it, blow it — a piece of cloth that will not
19071911
fight back.</p>
19081912
</a>
1913+
<a class="misc-card" href="./ipc_demo/index.html">
1914+
<span class="misc-card-icon misc-card-icon--ipc" aria-hidden="true">
1915+
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
1916+
<rect x="10" y="26" width="15" height="15" rx="4" fill="#5b8def" transform="rotate(-6 10 26)" />
1917+
<ellipse cx="31" cy="20" rx="9.5" ry="8.5" fill="#34a853" />
1918+
<path d="M5 14c6 5 14-6 20-1s12-6 18-1" stroke="#ea4335" stroke-width="2.4"
1919+
stroke-linecap="round" fill="none" />
1920+
</svg>
1921+
</span>
1922+
<p class="misc-card-title" data-en="IPC Simulation" data-zh="IPC 物理模拟">IPC Simulation<svg
1923+
class="misc-card-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor"
1924+
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
1925+
<path d="M7 17 17 7M9 7h8v8" />
1926+
</svg></p>
1927+
<p class="misc-card-desc"
1928+
data-en="Squishy bodies and draping rods, simulated on GPU with guaranteed zero penetration — replayed in your browser."
1929+
data-zh="软体挤压与绳索垂落,GPU 上模拟、保证零穿透 —— 在浏览器里回放。">Squishy bodies and draping rods, simulated on GPU
1930+
with guaranteed zero penetration — replayed in your browser.</p>
1931+
</a>
19091932
</div>
19101933
</section>
19111934

ipc_demo/PLAN.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# IPC 网页演示 — 实施计划与进度
2+
3+
> 完整调研背景见 git 历史或 `~/.claude/plans/ipc-incremental-potential-contact-fuzzy-karp.md`
4+
> 本文件是**执行进度追踪**,每完成一小项就勾掉一项并在"进度日志"补一行,方便随时中断/继续。
5+
6+
## 方案概要
7+
8+
离线用 GPU (RTX 2070) 跑 [libuipc](https://github.com/spiriMirror/libuipc) (`pip install pyuipc`) 生成 IPC 仿真逐帧数据,
9+
烘焙成紧凑二进制,浏览器端用 three.js (CDN) 纯静态回放。两个场景:
10+
11+
1. **soft_stack** — 多个软体互相堆叠挤压(改自 libuipc-samples `34_cloth_stack`
12+
2. **rod_tangle** — 绳/链条 codimensional 接触(改自 libuipc-samples `23_kirchoff_rod_bending`
13+
14+
目录结构:
15+
16+
```
17+
ipc_demo/
18+
PLAN.md # 本文件
19+
gen/ # 离线生成(公开)
20+
requirements.txt
21+
export.py # headless 逐帧导出工具
22+
bake.py # npz → mesh.json + frames.bin (Int16 量化)
23+
scenes/soft_stack.py
24+
scenes/rod_tangle.py
25+
data/<scene>/mesh.json + frames.bin # 网页直接 fetch
26+
index.html # three.js 回放页
27+
```
28+
29+
## 任务清单
30+
31+
- [x] **1. 环境搭建**:源码编译 libuipc(sm_75)成功,pyuipc 0.9.0 装进 venv;FEM 冒烟测试通过(四面体真实下落、位置有更新、无穿地)。⚠️ 两个坑:① venv 的 python 来自 miniconda,其 RPATH 会优先加载 conda 旧版 libstdc++,需 `LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libstdc++.so.6`(已写进 env.sh);② ABD(AffineBody)几何的 `positions()` 不随仿真更新(状态在 instance transform 里),FEM 本构(StableNeoHookean 等)才更新——录制动的物体必须用 FEM,固定不动的 ABD 无所谓
32+
- [x] **2. 导出工具**`export.py`(headless 步进 + 逐帧收集 `positions()` + 存 npz)— 已写好,Transform/Vector3/AngleAxis API 已用 pip 版验证
33+
- [x] **3. 场景 1 soft_stack**:跑通并烘焙完成。bunny+2 球+2 圆环(6145 顶点,300 帧),落地堆叠挤压、最低点 y≈0.01(=d_hat 间隙,零穿透)、末帧静止。第一版曾因资产网格尺寸不一(ball.msh 半径 4 不居中)初始穿透被 sanity checker 拒绝 → 已改为 numpy 归一化后摆放。浏览器截图验证 OK。
34+
- [x] **4. 场景 2 rod_tangle**:跑通并烘焙完成。14 根绳(49 顶点/根)交叉下落搭在固定圣诞球上(ball.msh 原来是带挂环的装饰球)。第一版绳太软+逐点抖动导致"皱面条"且全部滑落 → 调参:KirchhoffRodBending 1e-2→5e2、摩擦 0.3→0.5、去抖动、起始位置贴近球顶,效果良好(几根挂在球上、其余盘绕球底)。
35+
- [x] **5. 数据检查**:两场景均过——录制器断言 finite、final_speed≈0、最低点 y≈d_hat(零穿透)、浏览器目测无穿模;delta 编码 roundtrip 误差 0.047mm
36+
- [x] **6. bake.py**:npz → `mesh.json` + `frames.bin`(Int16 量化)— 已写好并用合成数据测试通过
37+
- [x] **7. index.html**:three.js 回放页 — 已写好;用合成数据 + headless Chromium (playwright) 验证:渲染、阴影、fat-line 绳、播放/时间轴/主题切换/场景切换均正常、无控制台错误
38+
- [x] **8. 主站集成**:主页 misc 区块已加"IPC Simulation"卡片(双语)
39+
- [x] **9. 端到端验证**:headless Chromium 全套通过——两场景加载播放、about 弹窗、倍速、明暗主题、移动端 390px 视口、主站卡片跳转,全程无控制台错误。真实数据总体积 2.4MB。
40+
- [ ] **10. (可选) 耦合场景**:绳子搭在软体堆上的混合演示 —— 未做,随时可加(复用 soft_stack + rod 的搭建代码即可)
41+
42+
## 收尾备注(2026-07-18)
43+
44+
- **全部核心任务完成,未提交 git**(等用户确认后再 commit/push)。
45+
- 本地预览:仓库根目录 `python3 -m http.server 8642`http://localhost:8642/ipc_demo/index.html
46+
- 重新生成数据的完整流程:`cd ipc_demo/gen && source env.sh && python scenes/soft_stack.py && python scenes/rod_tangle.py && python bake.py raw/soft_stack.npz ../data/soft_stack && python bake.py raw/rod_tangle.npz ../data/rod_tangle`
47+
- 依赖的本机环境(不入库):`~/Toolchain/libuipc`(源码+build)、`~/cuda-12.8``~/.local/tools`(zip/ninja/chromium 库等)、`gen/.venv`
48+
- GitHub Pages 是静态托管,`.gz` 文件会原样返回,viewer 里用 `DecompressionStream` 手动解压,不依赖服务器 Content-Encoding。
49+
50+
## 进度日志
51+
52+
- 2026-07-18: 计划批准,开始实施。
53+
- 2026-07-18: venv 建好,`pip install pyuipc==0.0.25 numpy` 成功;补装了 `nvidia-cublas/cusparse/cusolver/cuda-runtime-cu12`(wheel 不捆绑,需加到 `LD_LIBRARY_PATH`)。
54+
- 2026-07-18: 冒烟测试失败:`cudaErrorSymbolNotFound`。用 `strings` 检查确认 **PyPI wheel(新旧版本都是)只编译了 sm_89**,RTX 2070 是 sm_75 → 决定源码编译。注意 pyuipc 的 `positions()` 视图形状是 `(N,3,1)` 列向量。
55+
- 2026-07-18: 编译前置检查:CMake 3.31.2 ✓、g++ 11.4 ✓、16 核/31GB 内存/911GB 磁盘 ✓;缺 CUDA toolkit 和 zip/unzip/pkg-config/ninja/libtool(需 sudo 安装,等用户执行);NVIDIA keyring deb 已下载到 scratchpad。
56+
- 2026-07-18: 无 sudo 方案落地:zip/unzip/pkg-config/ninja 从 Ubuntu deb 解包到 `~/.local/tools/usr/bin`(需加进 PATH);CUDA 12.8.1 runfile 正在下载到 scratchpad(装到 `~/cuda-12.8`,不需要 root);vcpkg 和 libuipc 已 clone 到 `~/Toolchain/`,vcpkg 已 bootstrap;14 个 C++ 依赖(eigen/libigl/tbb 等)正在后台预编译暖缓存。
57+
- 2026-07-18: 场景 1 soft_stack 跑通(第二版,网格归一化后);烘焙 2.15MB(delta 编码把 5.31MB 压到 2.15MB);浏览器截图验证堆叠挤压效果。场景 2 rod_tangle 第一版效果差(皱面条+全滑落),调参重跑后效果好,烘焙 0.17MB。端到端最终验证全过。**项目完成,待 git 提交。**
58+
- 2026-07-18: CUDA 12.8 toolkit 装好(runfile 直接 `--silent` 会报 exec 错,用 `bash xxx.run --extract=DIR` 解包后把 cuda_nvcc/libcublas 等组件 `cp -a` 合并进 `~/cuda-12.8`,nvcc 12.8.93 可用);vcpkg 14 个依赖 5.9 分钟全部编好进二进制缓存(注意 manifest 需要 vcpkg.json + vcpkg-configuration.json 两个文件都在)。
59+
- 2026-07-18: libuipc CMake configure 成功(Ninja / Release / sm_75 / pybind on / tests·examples·benchmarks off,python 指向 `gen/.venv`),正在后台编译。恢复方法:`cd ~/Toolchain/libuipc/build && PATH=$HOME/.local/tools/usr/bin:$HOME/cuda-12.8/bin:$PATH cmake --build . -j14`,编完后 pybind 应自动装进 venv(若没有则 `cd build/python && pip install .`),然后跑 `gen/smoke_test.py`(先 `source gen/env.sh`)。
60+
- 2026-07-18: 编译成功(~1090 目标,约 35 分钟),pyuipc 0.9.0 自动装入 venv;数据格式升级:录制器支持 static 物体、bake 降采样到 30fps + gzip(`frames.bin.gz`),viewer 用 `DecompressionStream` 解压、静态物体只写一次。
61+
- 2026-07-18: 编译等待期间完成 6/7/8 号任务:`bake.py``index.html` 回放页、主站卡片全部就位。验证方法:合成动画数据(scratchpad/make_fake_npz.py)走完整 bake 管线放进 `data/`,本地 `python -m http.server 8642`(在仓库根目录),headless Chromium(playwright,chromium 系统库同样用 deb 解包进 `~/.local/tools`,跑时需 `LD_LIBRARY_PATH=$HOME/.local/tools/usr/lib/x86_64-linux-gnu`)截图确认渲染正确。**注意:目前 `data/` 里是假数据占位,真数据跑出来后覆盖。**
170 KB
Binary file not shown.

ipc_demo/data/rod_tangle/mesh.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.
2.05 MB
Binary file not shown.

ipc_demo/data/soft_stack/mesh.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

ipc_demo/gen/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.venv/
2+
raw/
3+
__pycache__/
4+
smoke_output/

ipc_demo/gen/bake.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Bake raw .npz recordings into compact web playback assets.
2+
3+
Input : gen/raw/<scene>.npz (from export.Recorder)
4+
Output: data/<scene>/mesh.json topology + metadata (+ static object positions)
5+
data/<scene>/frames.bin.gz gzip'd Int16-quantized per-frame positions
6+
7+
frames.bin layout (before gzip): frame-major, little-endian int16,
8+
delta-encoded: frame 0 holds absolute quantized values, every later frame
9+
holds the (wrapping) int16 difference from the previous frame — smooth motion
10+
makes deltas tiny, so gzip compresses far better. The viewer integrates the
11+
deltas back into absolute values once, right after decompression.
12+
For each kept frame: for each NON-static object (in mesh.json order): N*3 int16.
13+
Dequantize: pos = min + (q / 32767) * (max - min), per-axis over the whole clip.
14+
Static objects appear once in mesh.json as a quantized "positions" array.
15+
16+
Usage: python bake.py raw/soft_stack.npz ../data/soft_stack [--subsample 2]
17+
"""
18+
19+
import gzip
20+
import json
21+
import os
22+
import sys
23+
24+
import numpy as np
25+
26+
27+
def bake(npz_path: str, out_dir: str, subsample: int = 2):
28+
raw = np.load(npz_path)
29+
count = int(raw["count"])
30+
fps = float(raw["fps"]) / subsample
31+
32+
objects = []
33+
for i in range(count):
34+
static = bool(raw[f"obj{i}_static"]) if f"obj{i}_static" in raw else False
35+
frames = raw[f"obj{i}_frames"]
36+
objects.append(
37+
{
38+
"name": str(raw[f"obj{i}_name"]),
39+
"kind": str(raw[f"obj{i}_kind"]),
40+
"static": static,
41+
"indices": raw[f"obj{i}_indices"],
42+
"frames": frames if static else frames[::subsample],
43+
}
44+
)
45+
46+
moving = [o for o in objects if not o["static"]]
47+
num_frames = moving[0]["frames"].shape[0]
48+
assert all(o["frames"].shape[0] == num_frames for o in moving)
49+
50+
# Global per-axis bounds over the whole animation (all objects)
51+
mins = np.min([o["frames"].min(axis=(0, 1)) for o in objects], axis=0).astype(np.float64)
52+
maxs = np.max([o["frames"].max(axis=(0, 1)) for o in objects], axis=0).astype(np.float64)
53+
span = np.where(maxs - mins < 1e-9, 1.0, maxs - mins)
54+
55+
def quantize(p):
56+
return np.round((p.astype(np.float64) - mins) / span * 32767.0).astype("<i2")
57+
58+
os.makedirs(out_dir, exist_ok=True)
59+
60+
# frames.bin: frame-major; delta-encode across frames for compressibility
61+
quantized = [quantize(o["frames"]) for o in moving] # per object: (F, N, 3) int16
62+
chunks = []
63+
for f in range(num_frames):
64+
for q in quantized:
65+
block = q[f] if f == 0 else (q[f].view(np.uint16) - q[f - 1].view(np.uint16)).astype("<u2")
66+
chunks.append(block.astype("<i2", copy=False).tobytes() if f == 0 else block.tobytes())
67+
bin_path = os.path.join(out_dir, "frames.bin.gz")
68+
with open(bin_path, "wb") as fh:
69+
fh.write(gzip.compress(b"".join(chunks), 9))
70+
71+
meta = {
72+
"fps": fps,
73+
"numFrames": num_frames,
74+
"bounds": {"min": mins.tolist(), "max": maxs.tolist()},
75+
"objects": [
76+
{
77+
"name": o["name"],
78+
"kind": o["kind"],
79+
"static": o["static"],
80+
"numVerts": int(o["frames"].shape[1]),
81+
"indices": o["indices"].reshape(-1).tolist(),
82+
**({"positions": quantize(o["frames"][0]).reshape(-1).tolist()} if o["static"] else {}),
83+
}
84+
for o in objects
85+
],
86+
}
87+
json_path = os.path.join(out_dir, "mesh.json")
88+
with open(json_path, "w") as fh:
89+
json.dump(meta, fh, separators=(",", ":"))
90+
91+
total = sum(o["frames"].shape[1] for o in objects)
92+
print(
93+
f"{out_dir}: {len(objects)} objects ({len(moving)} moving), {total} verts, "
94+
f"{num_frames} frames @ {fps:g}fps, frames.bin.gz={os.path.getsize(bin_path) / 1e6:.2f}MB "
95+
f"mesh.json={os.path.getsize(json_path) / 1e3:.0f}KB"
96+
)
97+
98+
99+
if __name__ == "__main__":
100+
if len(sys.argv) < 3:
101+
print(__doc__)
102+
sys.exit(1)
103+
sub = int(sys.argv[sys.argv.index("--subsample") + 1]) if "--subsample" in sys.argv else 2
104+
bake(sys.argv[1], sys.argv[2], sub)

ipc_demo/gen/env.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Source this before running scene scripts: source env.sh
2+
# Sets up the venv python + CUDA runtime libraries on LD_LIBRARY_PATH.
3+
VENV="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)/.venv"
4+
NV="$VENV/lib/python3.12/site-packages/nvidia"
5+
# /usr/lib first so the system libstdc++ (GLIBCXX >= 3.4.30) beats any conda one
6+
export LD_LIBRARY_PATH="/usr/lib/x86_64-linux-gnu:$NV/cublas/lib:$NV/cusparse/lib:$NV/cusolver/lib:$NV/cuda_runtime/lib:$NV/nvjitlink/lib:$NV/cuda_nvrtc/lib:$HOME/cuda-12.8/lib64${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
7+
# The venv python comes from miniconda, whose RPATH pins conda's older libstdc++
8+
# ahead of LD_LIBRARY_PATH — preload the system one so GLIBCXX_3.4.30 resolves.
9+
export LD_PRELOAD="/usr/lib/x86_64-linux-gnu/libstdc++.so.6${LD_PRELOAD:+:$LD_PRELOAD}"
10+
export PATH="$VENV/bin:$HOME/cuda-12.8/bin:$PATH"

ipc_demo/gen/export.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Headless per-frame recorder for libuipc simulations.
2+
3+
Tracks geometry slots of interest, captures vertex positions every frame,
4+
and saves everything (topology once + per-frame positions) into a compressed
5+
.npz that bake.py later converts into the web playback format.
6+
"""
7+
8+
import numpy as np
9+
10+
import uipc
11+
import uipc.builtin as builtin
12+
13+
14+
class Recorder:
15+
"""Collects per-frame vertex positions for a set of geometry slots."""
16+
17+
def __init__(self, fps: float):
18+
self.fps = fps
19+
self.entries = [] # list of dicts: name, slot, kind, indices, frames
20+
21+
def track(self, name: str, geo_slot, kind: str, static: bool = False):
22+
"""Register a geometry slot to record.
23+
24+
kind: "tri" (tet/shell mesh rendered as surface triangles)
25+
or "rod" (line mesh rendered as fat lines).
26+
static: object never moves — record frame 0 only.
27+
"""
28+
assert kind in ("tri", "rod")
29+
self.entries.append(
30+
{"name": name, "slot": geo_slot, "kind": kind, "static": static,
31+
"indices": None, "frames": []}
32+
)
33+
34+
def _extract_topology(self, entry):
35+
geo = entry["slot"].geometry()
36+
if entry["kind"] == "tri":
37+
tris = np.array(uipc.view(geo.triangles().topo())).reshape(-1, 3)
38+
is_surf_slot = geo.triangles().find(builtin.is_surf)
39+
if is_surf_slot is not None:
40+
is_surf = np.array(uipc.view(is_surf_slot)).reshape(-1)
41+
tris = tris[is_surf == 1]
42+
entry["indices"] = tris.astype(np.int32)
43+
else:
44+
edges = np.array(uipc.view(geo.edges().topo())).reshape(-1, 2)
45+
entry["indices"] = edges.astype(np.int32)
46+
47+
def capture(self):
48+
"""Call after world.advance() + world.retrieve() each frame."""
49+
for entry in self.entries:
50+
if entry["indices"] is None:
51+
self._extract_topology(entry)
52+
if entry["static"] and entry["frames"]:
53+
continue
54+
geo = entry["slot"].geometry()
55+
pos = np.array(uipc.view(geo.positions()), dtype=np.float32).reshape(-1, 3)
56+
entry["frames"].append(pos)
57+
58+
def save(self, path: str):
59+
data = {"fps": np.float32(self.fps), "count": np.int32(len(self.entries))}
60+
for i, entry in enumerate(self.entries):
61+
frames = np.stack(entry["frames"]) # (F, N, 3)
62+
assert np.isfinite(frames).all(), f"NaN/Inf in '{entry['name']}'"
63+
data[f"obj{i}_name"] = np.str_(entry["name"])
64+
data[f"obj{i}_kind"] = np.str_(entry["kind"])
65+
data[f"obj{i}_static"] = np.bool_(entry["static"])
66+
data[f"obj{i}_indices"] = entry["indices"]
67+
data[f"obj{i}_frames"] = frames
68+
np.savez_compressed(path, **data)
69+
total = sum(e["frames"][0].shape[0] for e in self.entries)
70+
nf = len(self.entries[0]["frames"])
71+
print(f"saved {path}: {len(self.entries)} objects, {total} verts, {nf} frames")
72+
73+
def report(self):
74+
"""Print simple end-of-run sanity stats (motion should settle)."""
75+
for entry in self.entries:
76+
frames = np.stack(entry["frames"])
77+
tail_motion = np.abs(frames[-1] - frames[-2]).max() * self.fps if len(frames) > 1 else 0.0
78+
print(
79+
f" {entry['name']:20s} kind={entry['kind']} verts={frames.shape[1]:5d} "
80+
f"y_range=[{frames[..., 1].min():+.3f}, {frames[..., 1].max():+.3f}] "
81+
f"final_speed~{tail_motion:.4f} m/s"
82+
)

0 commit comments

Comments
 (0)