LiteReality-Agent笔记: 从一次扫描到可交互的房间

好久没写点正经的东西了,上一篇还是折腾容器的事。这两天读了一个叫LiteReality-Agent的项目,是用手机扫一遍房间,然后端到端生成一个可编辑、可渲染、门窗抽屉都能动的三维场景。
本来只想看看它怎么调模型的,结果被它的工程组织方式吸引了,索性做个笔记。主要记录它的流水线结构、agent的关进笼子的方式,以及几处让我后背发凉的坑。


RGBD扫描与重建结果

先说问题在哪

手机扫房间在2026年已经不难了,Apple的RoomPlan走一圈就能给出一串RGB帧、深度、ARKit相机位姿,以及一个room.usdz,里面有墙面、门窗洞口和一组粗糙的物体包围盒。
难的是这堆东西离「能用的三维场景」还差得远。

RoomPlan给的是物体包围盒加类别标签,做图形要的是有真实几何和材质的网格;
给的是不可编辑的usdz,要的是能改、能重编译、能导出的场景源;
给的是静态几何,要的是门会转、抽屉会拉、洗碗机门会翻下来;
给的是「大概像个房间」,要的是「就是这间屋子」。

最后一条最容易被忽略。多数生成式场景重建的验收标准是「看起来像个合理的房间」,而不是「和我扫的那间对得上」。这个项目整条设计都在咬后者。

顺手数了一下规模: src/下183个python文件,28219行;tests/下41个test_*.py;100个提交;要求python >=3.10,<3.13;Apache-2.0;版本号还是0.0.1,Development Status写的Alpha。
一个两万八千行的alpha项目,架构文档能和代码逐条对得上,这事本身就不多见。

一条命令

支持的入口只有一个CLI。

1
uv run litereality run /path/to/capture

没有扫描数据也能试,官方放了示例房间。

1
2
git clone https://github.com/LiteReality/example-scans.git
uv run litereality run example-scans/<scan>

跑完之后直接在浏览器里走进去。

1
uv run litereality view run/<scan>

产物是通用格式,room_preview/Room.glb材质已烘焙、动画clip完整,Blender/Unity/Unreal/Web都能吃;room_preview/Room.blend是同一个房间的Blender场景;room/是可编辑的源,整个房间在这里定义。

五个stage,两个phase

架构文档第一句就把全貌摊开了。

1
2
cli.py → PipelineRunner → scene_init → realism_authoring
ingest → reconstruct → seed author → publish

五个公开stage声明在pipeline/stages.py里,十几行看完。

1
2
3
4
5
6
7
STAGES = (
Stage("ingest", ingest.run, is_complete=ingest.complete),
Stage("reconstruct", reconstruct.run, ("ingest",), is_complete=reconstruct.complete),
Stage("seed", seed.run, ("reconstruct",), is_complete=seed.complete),
Stage("author", author.run, ("seed",), is_complete=author.complete),
Stage("publish", publish.run, ("author",), is_complete=publish.complete),
)

每个stage带两样东西: 前置依赖元组,和一个is_complete(context)谓词。这两个撑起了整个可恢复语义,后面会看到。

确定性与agentic的分界

这是整个项目最重要的一条线。
scene_init确定性的——读扫描、检测、生成物体、拼成种子房间,同样输入出同样东西,没有模型在里面拍脑袋做布局决策。realism_authoring是agentic的——agent拿着种子房间,对着原始扫描的照片看,然后改代码,直到房间和照片对上。
两半可以各自单独跑。

1
2
uv run litereality run /path/to/capture --through seed
uv run litereality stage author run/my-room --force --polish --live

--polish在authoring之后追加三个可选pass: 物体精修、材质、模型驱动的质量检查。--live开一个浏览器视图,边跑边看房间被搭起来,旁边是agent的完整trace。
把这条线画明,调试时永远知道该骂谁: 布局歪了是seed的问题,材质不对是agent的问题。很多把LLM塞进流水线的项目坏在这条线是糊的。

PipelineRunner

pipeline/runner.py只有154行,但把可恢复流水线这件事做对了。状态落在run/<scene>/.litereality/pipeline.json,原子写。

1
2
3
4
5
def _write_state(self, context: RunContext, state: dict[str, Any]) -> None:
context.state_path.parent.mkdir(parents=True, exist_ok=True)
tmp = context.state_path.with_suffix(".tmp")
tmp.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
tmp.replace(context.state_path)

先写.tmpreplace,中途断电不会留下半个JSON。基本功,但很多人不做。

force会级联失效下游

1
2
3
4
forced_indexes = [i for i, stage in enumerate(self.stages) if stage.name in force]
invalidate_from = min(forced_indexes) if forced_indexes else len(self.stages)
for stage in self.stages[invalidate_from:]:
prior.pop(stage.name, None)

强制重跑reconstruct,那么seedauthorpublish的完成记录全部作废。这是对的,上游变了下游的「已完成」就是谎话。见过太多流水线在这里偷懒,结果拿旧的authoring结果配新几何,产出一个谁也解释不了的场景。

磁盘状态可以反过来认领内存状态

1
2
3
4
5
6
for prerequisite in stage.prerequisites:
dependency = self.by_name[prerequisite]
if (prerequisite not in completed
and dependency.is_complete is not None
and dependency.is_complete(context)):
reused = StageResult(prerequisite, StageStatus.REUSED)

直接跑litereality stage author,runner发现seed没在状态文件里,但磁盘上Room.py确实躺着,那就承认它,标成REUSED。这让状态文件丢了不再是灾难,磁盘产物本身就是事实来源。

环境变量的兼容桥被关在编排边界

一部分从旧代码移植来的stage还在读全局环境变量。作者没有逐个去改,而是在唯一一处用patch.dict把环境注进去,stage一返回立刻还原调用方的进程环境。

1
2
3
4
5
# Several ported stage implementations still read canonical environment
# names. Keep that compatibility bridge at the orchestration boundary and
# restore the caller's process immediately after the stage returns.
with patch.dict(os.environ, context.environment, clear=True):
result = stage.run(context, stage_options)

这是我很欣赏的处理历史债的姿态: 把脏东西收敛到一个点,并在注释里写明它为什么在那里。既不假装它不存在,也不为了洁癖去做一次高风险大重构。

CLI层的状态输出也干净。

1
2
3
4
ingest completed 142.3s
reconstruct reused 0.0s
author skipped 0.0s
publish failed 12.1sfinal compile failed; see ...

房间是一段可读的程序

这是整个项目最聪明的一个决定: 房间的源不是JSON,不是usdz,是一个python程序。
格式契约如下。

1
2
3
4
5
6
7
Room/
├── Room.py 语义外壳 + 物体摆放程序
├── Room.md 编辑指南(给agent看的)
├── manifest.json 物体到RoomPlan的映射
└── Objects/
├── Procedural/<name>/ object.py, object.md, textures.json
└── Static/<name>/ 源GLB + 统一的object.py包装

编译产物单独放,随时可重新生成。

1
2
3
4
5
room_preview/
├── Room.glb
├── Room.blend
├── room_layout.json
└── Object/

Room.py里嵌的是可读的几何: 墙的端点、门窗洞口、地板天花板高度、物体包围盒。程序化物体保留可编辑的Blender构建代码和纹理配方,神经网络生成的静态资产保留源GLB。
为什么这个决定重要? 因为它让「agent编辑场景」退化成了「agent编辑代码」——一个LLM已经很擅长的问题。不需要发明场景编辑DSL,不需要给agent一堆move_object(id, x, y, z)工具,agent就用它最熟的Read/Edit/Write改python文件,然后重新编译看结果。
room_ops包拥有这个表示,以及所有不依赖流水线状态的操作: 读写manifest、编译Blender和GLB、渲染、导出、起可行走的viewer。公开API就两行。

1
2
3
4
from litereality_agent.room_ops import compile_room, export_scene
room = export_scene("office-elliott")
glb = compile_room(room)

还有一条纪律: import room_ops不会启动Blender,只有显式的compile/render操作才会。

agent的能力工具是个闭集

agent手里除了Read/Edit/Write/Glob,还有六个领域能力工具,agent/tools/default_registry.py是唯一真值来源。

fetch_material: 从Poly Haven抓真实PBR材质集(diffuse+rough+normal),可选重新着色
select_views: 为房间、某面墙或某个物体挑最合适的采集帧,绝不让agent猜帧号
render: 针对一个目标层出「渲染图 ‖ 照片」对照,自动取景并重新编译
grid: 在表面拼图上画公制标尺,读出装置的(u, z)米坐标而不是估
critic: VLM打分,对着目标给{pass, score, issues},是判官
check_collisions: 真网格穿插和包含检测,object↔object、穿墙、出房间、离地,还有机器人风格的铰接门窗检查,每条都附一个公制的挪动或缩放修正建议

关键设计在select_viewsgrid这两个: 凡是agent容易「合理地猜错」的量,都变成一次工具调用去读。帧号、米制坐标,这些东西LLM编起来毫无阻力,而且编出来的值看着完全合理。做成可查询的工具,比在prompt里写「请不要猜」有效得多。
critic是个VLM judge,输入图像和一句目标描述,输出结构化的通过与否加问题列表。有了它authoring才成为闭环,而不是一次开环生成。
另外compile不是注册工具,它是render内部用的共享代码。agent只能通过render顺带编译,不能单独调编译——少一个工具,少一条走错的路。

退役的工具也留了记录

agent/tools/README.md里有个「retired tools」小节,写着旧的闭环驱动器(agent/harness/loop.py,一套11个原语加3个复合的闭集配裸API循环)已经删除,退役的原语被停放在legacy/下仅供参考,活跃路径没有任何东西import它们。
把「我们试过什么、为什么不用了、残骸在哪」写进文档,比只留下当前状态有价值得多。半年后有人问「为什么不做成闭集工具」,答案在仓库里。

harness和model是两个正交旋钮

agent/providers/base.py开头就点明的抽象,我认为是这个项目最有普适价值的一条。

harness agent循环: 文件工具、权限模型、MCP接线、hooks、事件流
model 循环里的那颗脑子(HARNESS_MODELLR_PROCEDURAL_MODEL…)

选择靠配置解析,优先级是LR_<ROLE>_PROVIDER > LR_AGENT_PROVIDER > claude。支持两个harness: claudeclaude_agent_sdkcodexcodex exec

harness不是功能等价的,而且这件事被显式建模了

每个harness声明一个supports集合,调用方分支判断而不是假设。

hooks: 能在工具调用真正执行前拒绝或引导它,用于step budget的优雅收尾
inproc_tools: 能力工具跑在本进程,不用stdio MCP子进程
cost: 会报告这次session花了多少钱
skills: 原生加载.claude/skills
restricted: 工具白名单真的被执行(Codex永远有shell权限)

源码注释里那句话写得很好。

Harnesses are NOT feature-equivalent, and pretending otherwise is how a step budget silently stops existing.

具体差异: Claude Code在进程内托管能力工具、能用PreToolUse hook引导session收尾、报告成本、遵守工具白名单、把文件读取暴露成可观测的Read调用。Codex跑在进程外,能力工具通过agent/tools/mcp_server.py走stdio MCP桥接,step budget退化成硬停,shell权限没法收回,不报成本。
然后是我最喜欢的部分——providers.describe()把这些差距打印到stage头部。

1
2
3
4
5
6
7
8
9
10
11
if spec.step_budget > 0:
native = "hooks" in harness.supports
parts.append(
f"step budget={spec.step_budget}"
+ (f" (wind-down at {max(1, spec.step_budget - max(0, spec.step_reserve))})"
if native
else f" (HARD STOP — {harness.name} has no pre-tool hook, no wind-down)")
)
...
if spec.file_tools and "restricted" not in harness.supports:
parts.append("WARNING: tool allowlist not enforced (shell access is always on)")

配套的注释是。

A missing capability must be visible in the log. …a run that quietly lost its graceful landing looks identical to one that never had it.

降级运行绝不能长得像正常运行。这条我准备直接搬到自己项目里。

step budget的优雅落地

SessionSpec里这两个字段设计得很细。

1
2
3
4
5
# Graceful landing: at `step_budget` tool calls the session ends; for the last `step_reserve`
# of them the capability tools are switched off so what remains goes to final edits.
# 0 disables. Honoured natively where "hooks" is supported, emulated (hard stop) otherwise.
step_budget: int = 0
step_reserve: int = 0

不是到点就砍,而是到点前留一段预算,把能力工具关掉,逼agent用剩下的步数把编辑落盘。做过长agent循环的人都懂这个痛: 硬停最恶劣的形态是agent刚渲染完、正准备改文件,被掐了,一轮全废。

事件流被归一化了

TextBlockToolUseBlockToolResultBlockAgentMessageSessionResult,字段名故意对齐claude_agent_sdk的block形状,这样ToolNarratorAgentTrace、拼图覆盖率检查、Room.py checkpointer这些消费者一套代码吃两个harness。
AgentMessage.raw保留harness自己的原始对象,所以raw trace sidecar是逐字保真的。normalise_blocks对不认识的block类型原样透传而不是丢掉。

Unknown blocks are kept rather than dropped: consumers ignore what they do not recognise, but the raw trace should never lose something because this mapping had not heard of it.

还有一条带血的注释,在ToolResultBlock上。

Carries tool_use_id, never the tool’s name — see tool_narration.py for what reading a name off this block cost us.

「从这个block上读工具名,让我们付了什么代价」。这种注释比任何设计文档都值钱。

复杂度路由: TRELLIS还是procedural

物体重建不是一条路走到黑,classify_complexity.py给每个物体做路由。

1
2
3
4
object_init产出干净参考图
→ classify_complexity路由
├── 椅子 / 沙发 / 抽象几何 → TRELLIS(神经生成,静态GLB)
└── 规则盒体 / 家电几何 → procedural(Blender原语 + PBR)

这个划分很务实。TRELLIS擅长有机形状,但它给不了你正确的关节;而桌子、储物柜、洗碗机、电视、水槽这类东西几何规则,但运动方式必须对。
procedural路线的核心洞见写在models/object_generation/README.md里。

RoomPlan only detects a fixed set of object categories, so we hand-author a detailed spec per category — geometry, materials, and exactly how each part moves.

因为RoomPlan的类别集是封闭有限的,所以「每个类别手写一份详细规格」是可完成的工作量,不是无底洞。category_specs.py是16KB纯领域知识,定义了每类物体的几何、材质,以及每个部件具体怎么动。生成时把类别规格、该物体真实的RoomPlan尺寸、干净参考图一起注入agent prompt。

the dishwasher door drops to horizontal, the drawer pulls out along +Y, the cabinet door swings on its outer vertical edge

洗碗机门翻到水平、抽屉沿+Y拉出、柜门绕外侧竖边旋转。这些不是模型猜的,是规格里写死的。
产物里每个运动部件带glTF node extras,有articulation_type(revolute或prismatic)、articulation_axislimit_minlimit_max,仿真器可以直接读。这就是「可交互」三个字的落点,不是「能点击」,是物理仿真意义上的关节。
顺带,这条路线的驱动是一个Claude Code skill(image-to-articulated-glb),装在articulated-glb-agent/.claude/skills/下,再被python launcher批量调用。用skill封装「图到铰接GLB」这个能力,这个组合挺有启发性。

最后一道闸门不含模型

agent会犯错,所以收尾是纯几何的。pipeline/room_qc/checks.py的docstring直接列清了要报什么。

below_floor / above_ceiling: 物体穿出地板或天花板
floating / sunk: 落地家具没落在地上
outside_room: 物体中心跑到房间轮廓外
wall_clash: 家具插进墙板
object_clash: 两件家具互相穿插
fixture_over_opening: 墙面装置压在门窗洞口上

关键在下一句: 正确的重叠不报。台下式水槽本来就在台面里、嵌入式烤箱本来就在橱柜里、椅子本来就塞在桌子下,这些在EXPECTED_CONTAINMENTPASSTHROUGH里白名单化。

Everything is pure arithmetic over AABBs, so it’s fast, exact, and needs no LLM.

一个agentic项目最后用纯AABB算术把关,这个层次感是对的: 能用算术判定的事情不要交给模型。报出来的问题由room_qc/fix.py挪动家具解决。
而且盒体运算只有一份,住在每轮agent调用都会用的那个工具里(agent/tools/check_collisions/source/geometry.py),checks.py只拥有「报告」这件事,因为「房间算不算过关」是流水线的决定。同一套几何判定,agent自查和流水线终检共用,不可能出现两套标准。

两个静默失效的教训

第一个是python-fcl不能做可选依赖。pyproject.toml里有一段异常长的注释。

QC true-mesh collision. NOT optional: publish runs room_qc.correct on every default run and only records a warning if it exits non-zero, so a missing FCL turned the deterministic clash gate into a silent no-op.

缺了FCL,那道确定性碰撞闸门就变成空操作,而且只留一条warning。作者的判断是预编译wheel覆盖所有支持的平台,代价是几MB和零编译,比发一个悄悄什么都不干的闸门便宜得多。
第二个是不烘焙材质,发布出去的是另一个房间。publish/__init__.py里的注释。

a procedural wall/floor/ceiling material (two_tone_mat, carpet_mat, ceiling_tile_mat) has no glTF representation, so it exports with neither a texture nor a baseColorFactor and renders WHITE. Flat-RGB and fetched-image materials survive either way, which is why this stayed invisible until a room used procedural ones.

程序化材质在glTF里没有表示,导出后既没纹理也没baseColorFactor,渲染成纯白。而平色和贴图材质两种路径都活得下来,所以这个bug一直隐身,直到某个房间真的用了程序化材质。修法是publish时显式调api.bake_room(),让agent看到的每张渲染图和最终产物走同一条烘焙路径。
这两条读的时候都有点后背发凉。验证通道自己失效是最难发现的一类bug,因为所有指示灯都是绿的。

配置

LiteRealitySettingspydantic-settings,加载顺序是进程环境 > .env > models.env > 类型化默认值。models.env是仓库自带的模型默认值,.env是你机器上的密钥和路径,每行用${VAR:-default}所以shell里已有的值一定赢。
一次性在组合边界解析完,然后apply_environment()setdefault铺到环境里,不覆盖调用方的shell。

两个校验细节值得看。未知harness必须在这里炸,而不是在付费session深处。

1
2
3
4
# An unknown harness name must fail HERE, at the composition boundary, rather than deep
# inside a paid session: `LR_AGENT_PROVIDER=codexx` would otherwise fall through to the
# claude default and silently run the wrong agent.
self.agent_provider = self._checked_provider(self.agent_provider) or "claude"

LR_AGENT_PROVIDER=codexx多打一个x,会静默fall through到claude默认值,跑错agent还花你的钱。
半个token pair不能算「已配置」。

1
2
3
4
5
6
7
def modal_credentials(self) -> tuple[str, str] | None:
"""...Half a token pair cannot authenticate, so it must not read as configured —
otherwise the runtime selects Modal and fails at the call instead of falling back
or saying what is missing."""
if self.modal_token_id and self.modal_token_secret:
return (...)
return None

只填了MODAL_TOKEN_ID没填secret,如果算作配置了Modal,运行时就会选择Modal然后在真正调用时失败,而不是回退或者告诉你缺什么。
还有一个挺可爱的兼容处理: models.env被python-dotenv读,而dotenv不展开用作默认值的$VAR,所以${VAR:-$OTHER}会原样到达变成字面量$other。代码把以$开头当作未设置。

1
2
if not chosen or chosen.startswith("$"):
return None

models.env

这个文件写得像文档,一个地方选完所有模型。

LR_AGENT_PROVIDER: 哪个agent harness,默认claude
HARNESS_MODEL: 写和改Room.py的编辑者,也驱动classify和VLM reader,默认claude-opus-5,这是主要旋钮
HARNESS_CRITIC_MODEL: VLM critic,默认跟随HARNESS_MODEL
LR_CHAIR_JUDGE_MODEL: 椅子类型判官(框架、扶手、软包、底座属性)
LR_PROCEDURAL_MODEL: 铰接GLB的程序化agent
LR_COMPLETENESS_MODEL: 完整性闸门,参考图对渲染图判「有没有缺件」
LR_OPENAI_IMAGE_MODEL: 参考图生成,默认gpt-image-2
LR_DINO_MODEL / LR_DINO_EMBED_MODEL: GroundingDINO检测与DINOv2分组嵌入

LR_COMPLETENESS_MODEL的注释很诚实。

Runs once per build attempt per object and sits on the critical path. …Lowering it is tempting but two-sided: too lenient ships objects missing parts, too strict costs a full agent rebuild.

降配是双向风险: 太松就发出缺零件的物体,太严就白烧一次完整重建。把这种权衡写在配置项旁边,而不是留给后人试错。
另外注释里明确写了claude-fable-5太贵且并不比claude-opus-5强,所以默认不用。这种基于实测的取舍记录,比任何benchmark表都实用。

运行时隔离

TRELLIS和GroundingDINO需要GPU,项目的默认选择是托管在Modal上。

1
2
3
4
uv sync --frozen --extra modal --group dev
cp .env.example .env
uv run litereality setup
SANITY_DEEP=1 uv run python sanity.py

理由说得很直白: 本机不跑重活,一台没独显的Apple Silicon Mac就够,而且检测能并行铺开到多个容器,不用在一张卡后面排队。有自己的Linux GPU就走deploy/local-gpu.md
绑定逻辑在models/registry.py,短得可以全文引用。

1
2
3
4
5
6
7
8
9
10
11
12
def gen3d_from_settings(settings=None):
settings = settings or load_settings()
if settings.modal_configured():
from litereality_agent.models.trellis.modal import ModalTrellisService
return ModalTrellisService(...)
if settings.trellis_python:
from litereality_agent.models.trellis.service import LocalTrellisService
return LocalTrellisService(python=str(settings.trellis_python))
raise RuntimeError(
"TRELLIS is not configured: set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET for hosted "
"execution (the default), or TRELLIS_PYTHON for an explicit local GPU runtime."
)

分层很清楚: model包拥有一条推理路径,runtime拥有「在哪执行」。deploy/modal/里是托管模型的包装,应用代码永远不import它。重依赖也隔离在自己的环境里(--extra detect--extra gen3d),不污染轻量的agent循环环境。
顺带,CLI和单元测试不会启动DINO、TRELLIS、Blender,也不发起付费调用。这条保证让「随手跑一下测试」成为可能。

工程纪律

这部分其实是我读完最想聊的。

依赖方向被测试执法

1
2
3
cli.py → pipeline → agent → room_ops
↘ models → runtimes
↘ room_ops

箭头从调用方指向依赖。models、room_ops、可复用agent永远不import pipeline。这条规则不是写在文档里靠自觉,是tests/test_architecture.py在跑的。
架构文档还专门写了不存在什么。

There are no top-level services, adapters, or shared packages. There is also no nested pipeline/stages package.

声明不存在的东西,是防止架构熵增的好办法。下一个人想加shared/之前,会先在文档里撞到这句话。

测试默认离线且快

1
2
3
4
5
6
addopts = "-m 'not blender and not scan and not live' --strict-markers"
markers = [
"blender: needs a Blender install ($LITEREALITY_BLENDER) — run with `-m blender`",
"scan: needs real scan data under scans_uploaded/ — run with `-m scan`",
"live: makes paid model calls — run with `-m live`",
]

注释是「The default run must stay offline and fast, so it is worth running before every commit.」三个marker把「需要Blender」「需要真实扫描」「要花钱」分层剥离,默认套件保持离线,所以它值得在每次提交前跑。加上--strict-markers,打错marker名字会直接失败而不是静默失效——又一个防静默的例子。
安全的本地验证只有三条命令。

1
2
3
uv run ruff check src tests sanity.py scripts
uv run pytest -q
uv build

根目录还有个24440字节的sanity.py,配SANITY_DEEP=1做深度检查。对一个依赖Blender加三个模型加一个云运行时加两个agent CLI的项目来说,把「你的环境到底行不行」做成一个可执行的自检,是省下无数issue的投资。

唯一副本,测试钉死

agent/tools/README.md里说明了哪些原语是共享而非某个工具独有的,以及为什么。

config.py: harness路径和旋钮,四个工具加pipeline的evidence.py都要
scan.py: scan_from_roomconfig_fortests/test_scan_inference.py钉死只存在一份
overlay.py: 墙面投影,composeselect_views都要
image_selection/: 表面几何加正视比较
stitch_wall_image/: 矫正后的墙面拼图,四处使用

一句话交代动机: 「a per-tool copy is a bug waiting to happen」。写个测试来保证某个函数全仓库只有一份,这个手法我以前没想到过,但确实是对付「复制一份改改」这种腐化的最直接办法。
另外有些包装是故意的: compile包着room_ops.compile_room(格式自己的编译器),fetch_material包着compile/fetch_textures(因为textures.json是Room格式契约的一部分)。文档里标了deliberately。把「这里看起来该合并但我们没合并」写清楚,省掉后人一次好心的错误重构。

抛开三维重建,能抄的几点

  • 把确定性的和agentic的显式分层,不让模型参与能用算术解决的决策,最后一道闸门用纯几何。
  • agent容易「合理地猜错」的量做成工具去读。帧号、米制坐标,LLM编这些毫无阻力且编得很合理。
  • 把场景或配置变成代码,让「编辑」退化成「改代码」,直接复用LLM最强的能力。
  • harness和model是两个正交旋钮,而且harness之间不是功能等价的,用supports集合显式建模差异。
  • 降级运行绝不能长得像正常运行,缺失的能力必须打进日志。
  • 长agent循环要留优雅落地的预算,到点前关掉能力工具。
  • 配置错误在组合边界炸掉,不要漏进付费session。
  • 警惕验证通道自己失效,这类问题只能靠「改输入看输出是否真的变」来抓。
  • 文档要写「不存在什么」和「我们试过什么」。
  • 把权衡写在配置项旁边,替后人省掉一轮试错。

上手清单

需要的东西: uv;Blender 5.x(测试于5.1,BLENDER_PATH指向安装目录而不是可执行文件);OpenAI API key用于参考图生成,通常每个场景不到一美元;一个已登录的agent CLI在PATH上,claude是默认,codex也支持;以及一个跑GPU的地方,Modal账号(推荐,免费额度够,Mac也能用)或者显存不小于24GB的Linux机器。平台上macOS的Apple Silicon和Linux都测过。

1
2
3
4
5
6
uv sync --frozen --extra modal --group dev
cp .env.example .env
uv run litereality setup
SANITY_DEEP=1 uv run python sanity.py
uv run litereality run /path/to/capture
uv run litereality view run/<scan>

扫描端是免费的LiteReality Scanner,走一圈就够。

局限

免得读起来像软文,说说不好的地方。
版本还是0.0.1,Development Status是Alpha,技术报告没出。整条推理链路挂在claudecodex的登录态上,不是纯API key就能起,这对复现和CI是个真实的摩擦点。成本也不透明,参考图生成有报价,但authoring那一大段跑在订阅制CLI上,注释里明说not metered,所以很难算清一个场景的真实开销,Codex harness干脆不报成本。
Codex路线是明确的二等公民,没有pre-tool hook、没有工具白名单、不报成本,文档诚实标出来了,但不用Claude的话体验是降级的。物体精修更是只支持Claude,因为render_object工具是围绕活跃session状态按物体动态构建的,stdio桥接没有registry条目可以重建。作者的处理是报明确的错然后失败,而不是在没有唯一自检工具的情况下硬跑,这个选择我赞成。

不过话说回来,这个项目真正的贡献我觉得不是「用LLM做三维重建」,而是演示了一套把agent关进笼子的工程范式。笼子的骨架是可恢复的确定性流水线、代码化的领域表示、闭集能力工具、不含模型的终检闸门,以及对降级路径的强制可见性。agent在笼子里干它最擅长的事——看图、改代码、迭代,笼子保证它改不出可验证边界之外的东西。
两万八千行代码、41个测试文件、100个提交,做到架构文档和代码逐条对得上,注释里还留着「这个bug让我们付了什么代价」,这种密度的工程自觉,2026年的agentic项目里不算常见。

仓库在这里,项目主页在这里

看完之后手有点痒,感觉自己那几个项目的日志该重写了。

分享
匿名评论