Blender 还是 Three.js:让 AI 把同一个场景建两遍 Blender or Three.js? Having AI Build the Same Scene Twice
想搞清楚的是一件很具体的事:把同一份任务书交给同一个模型(Claude Code,Opus 5),一边让它调用 Blender CLI 建模、再导出到网页,一边让它直接写 Three.js 代码,最后拿到的东西差在哪里——差在能力上,还是差在画面上。
这不是引擎跑分。两条工具链的渲染方式本来就不同,比帧率没有意义。有意义的是:下次接到一个「要一个能在浏览器里走进去的空间」的需求时,该从哪一头开始。
任务书两边完全一样,只有一句话:64 × 30 × 20 米的筒形拱顶候车大厅,晴天早上九点一刻,阳光从屋顶玻璃落进来,翻牌式发车板是画面的焦点。
A · Blender 由模型写 Python、通过 Blender CLI 全程生成。所有形状从基本体推出来,所有材质是程序化节点图,没有购买模型,也没有图片贴图。最后烘焙成一个紧凑的二进制文件(位置 int16,法线 int8),由一个手写的 WebGL2 查看器在单个 HTML 文件里渲染。Blender 这一侧的流程参考了 GLM 5.3 用 Blender 建场景的记录。
B · Three.js 直接写成场景代码,几何在运行时生成,不烘焙任何东西。它做得更晚,把 A 当视觉参考,连配色都按线性 albedo 移植了过来,所以两边的颜色本应对得上。B 的源码在 Makone。
两边都是 n = 1,同一个人、同一个模型。下面所有结论都该按这个样本量来读。
能力上限很高,和真正能交付的,是两件事
Blender 这一侧的天花板明显更高:全局光、体积散射、有真实折射的玻璃,面数几乎没有预算。离线渲染里这些都成立。
但没有一样活过了导出这一步。到了网页上,剩下的是一盏平行光加半球环境光、一张 2048² 的阴影贴图、一点距离雾。Cycles 的体积散射不在里面,玻璃的折射不在里面,能留下的只有几何和贴图。
Three.js 这一侧从第一行代码起就知道自己没有这些。它只能在 60 fps 的预算里手搓一切——这个限制后面会变成它最大的优势。
「能做出来」和「能交付」之间,隔着一次导出。 这句话是整个实验里最贵的一条结论。
不截图,把两边接到同一套摄影机上
各自截几张图然后肉眼比对,得到的只会是两组各自成立的好看画面。所以两个页面都打了同一个补丁:隐藏所有界面元素,停掉内置的 requestAnimationFrame 循环,然后暴露一个最小的外部接口。
window.__api = {
set(eye, at, fovDeg) { // 传进来的一律是 Blender 坐标
cam.position.set(eye[0], eye[2], eye[1]);
cam.up.set(0, 1, 0);
cam.lookAt(at[0], at[2], at[1]);
cam.fov = fovDeg;
cam.updateProjectionMatrix();
},
step(dt) { world.renderFrame(dt); },
caption(s) { document.getElementById('cap-shot').textContent = s; },
};
机位表只写一份,用 Blender 的坐标约定(X 向右,Y 进深,Z 向上);Three.js 那一侧在自己的 set() 里换成 (x, z, y)。两个引擎都接受垂直视角,所以取景是构造出来的一致,不是调出来的。
window.SHOTS = [
{ id:'long-axis', cn:'长轴 · 西入口',
eye:[-29.0, 0.40, 1.68], at:[24.0, -1.0, 6.40],
fov:56, dolly:3.2, drift:[0, 0, 0.10] },
{ id:'clock', cn:'四面钟特写',
eye:[ -3.6, 3.60, 4.55], at:[ 0.0, 0.0, 4.33],
fov:34, dolly:0.9, drift:[0, 0, -0.10] },
// …共九个机位,每个 4.5 秒
];
window.FPS = 24;
window.TOTAL_FRAMES = window.SHOTS.length * 108; // 972
采集是逐帧推进的,不是录屏:设好相机,只渲染一帧 dt = 1/24,截图,再来下一帧。
for (let i = 0; i < total; i++) {
await page.evaluate((i) => {
const c = window.camAt(i);
window.__api.set(c.eye, c.at, c.fov);
window.__api.step(1 / window.FPS);
}, i);
await page.screenshot({ path: `${out}/${String(i).padStart(4, '0')}.png` });
}
ffmpeg -framerate 24 -i frames/a/%04d.png -framerate 24 -i frames/b/%04d.png \
-filter_complex "[0:v]pad=1280:724:0:0:color=0x2a3138[t];[t][1:v]vstack=inputs=2" \
-crf 18 compare.mp4
确定性的步进才是这件事值得做的原因。实时录制会得到两段慢慢跑偏的视频;逐帧推进得到的是严格对齐的两列 972 帧,可以一帧一帧地拖着看。
第一帧:两个大厅互为镜像
两边用的是同一套分区约定——闸机与站台在一侧,商业拱廊与夹层在另一侧,大拱窗在东端。各自都自洽。
但 Blender 的 (x, y, z) 映射到 Three.js 的 (x, z, y) 是一次反射,而两个项目又各自把这套映射烘进了自己声明的机位里。结果是:站在西入口面向大拱窗,A 的拱廊在你左手边,B 的在右手边。

同一个约定,相反的手性。上下两张的眼位、目标点和垂直视角完全相同。
没有任何相机选择能修好这一点。B 就是 A 的镜像。把渲染结果水平翻过来确实能纠正手性,但屋里所有的字也会跟着镜像。要真的对齐,得改 B 的构造代码,或者改 A 的导出轴向约定。这两个场景各自单独存在时都是对的,一套共用的摄影机把它在第一帧就翻了出来。
论内容量,Blender 赢,而且不是小赢
A 明显更满。带招牌的商铺门脸、有编号窗口的售票厅、行李、手推车、垃圾桶、围栏排队区、导向立牌、停在站台上的列车、夹层咖啡厅里真的坐着人。
B 更空,家具也更大:长椅、垃圾桶、几棵树、一个亭子、几根围栏柱。拱廊的商铺开口是空的。夹层摆了桌子,没有人。

同一个机位,A 的画面里东西差不多多一倍。但地板讲的是相反的故事。
人群是单项差距最大的地方。 A 有 78 个人物,衣服颜色各不相同,其中 21 个沿着一张走廊网格行走,这张网格从长椅之间穿过,所以没有人会径直走过家具。每个人物的状态——位置、朝向、步态相位——存在一张两行的 RGBA32F 纹理里,顶点着色器用 texelFetch 读它;四肢在导出时按各自的局部坐标系逐顶点打了标签,于是走一步在着色器里只是一次旋转,完全不带蒙皮数据。他们会像真人一样聚在发车板前面。
B 有大约 13 个按脚本走动的角色,统一的浅色人偶,稀稀拉拉。
论画面好不好看,Three.js 赢——因为它做不了假
这是最出乎意料的一条,而且它的原因恰恰是限制。
任务书要的是「一条条分明的阳光躺在地板上」。九个机位跑下来,只有 B 真的把这句话拍出来了。
A 做了一整套玻璃桁架拱顶——肋、檩条、交叉支撑,完整的钢网。阳光穿过整片玻璃面,落到地上的是被网格切碎的散光。光交给了解算器,导出回来是一屋子又平又匀的光。
B 做不了体积光,所以它只剩下一条路:把屋面做实,只在拱面大约 25%、48% 和 71% 的位置切三条纵向采光带。不透明的部分投影,玻璃不投影,太阳于是自己在地上画出边缘清楚的条纹。画面里看得见的光束是几何体,每条带每一跨合并成一个加法棱柱,尘埃只在光束内部撒种,不散在整个房间里。

这一张解释了后面所有的光照差异。上方的顶是一张网,下方的顶是一块板上开的三条缝。
| A · Blender 导出 | B · Three.js | |
|---|---|---|
| 着色 | 前向:1 盏平行光 + 半球环境光,单张 2048² 阴影贴图,距离雾,类 Reinhard 压缩 | ACES filmic,曝光 0.86 |
| 灯光 | 太阳 + 半球 | 半球 0.55,环境 0.13,平行光 2.35,另加 2 盏 RectArea 和 1 盏点光补光 |
| 体积光 | 无——Cycles 的体积散射没有活过导出这一步 | 光束几何体 + 漂浮尘埃粒子 |
| 读起来像 | 平、偏冷、照度均匀,几乎没有阴影 | 暖、高对比,有真正的暗角 |
A 有照明,B 有一个关于照明的想法。做不了假的那个渲染器会逼你做决定,而做决定基本就是美术指导的全部。
推近之后,结论又翻过来
四面钟是这个房间的主角道具。推到 4.5 米,判断整个翻转。

A 的四列信息对齐得更好,字距也更均匀,但它是死的;B 的板有状态颜色,而且每 2.2 秒会有一行真的翻过去。
A 的表盘没有数字。 源文件确实为四个面都创建了文字对象,但它们既不在 WebGL 导出里,也不在离线的 Cycles 渲染里,所以这是场景在构建过程中丢的,不是导出的缺口。我从四个方位都重拍了一遍,怀疑过背面剔除,它们就是不存在。指针是两个方块,轴心是一个圆柱。
B 的表盘是一张 CanvasTexture——罗马数字、分刻度、渐收的指针、有真实高光的黄铜边框,而且指针在走。推到 4.5 米,它仍然是一只钟。

一个用几何体做的表盘,在所有真正要紧的距离上都输给一张 512² 的画布,还多花了三角形。
发车板是同一个故事。A 的排版更讲究——四列对齐,字距均匀,而且是真正的几何体——但它不会更新。B 的是 CanvasTexture,带状态色(准点 / 登车 / 晚点 / 换站台),每 2.2 秒有一行在 0.45 秒里翻过去。整个房间里,只有它带着新消息。
密度赢在远景,输在近景。
两边都有只能靠换机位找到的错
最后一个机位从东端沿长轴回望——这个角度两个项目自己的镜头表里都没有。共用一套摄影机的意义正在于此。

B 的发车板只做了能看清的那一面,背面从来没有被建出来。A 的板从两侧都成立。
把 A 侧记录在案的 18 个缺陷分一分类,比数量更说明问题:其中 10 个是纯粹的工具税——节点图、导出管线、坐标约定带来的麻烦,在 Three.js 里根本不会发生;剩下 8 个是空间类的,比如手性、单面几何、从没取过的角度,换任何渲染器都一样会有。
选工具能省掉的是前面那 10 个。后面那 8 个只能靠一套不属于任何一边的摄影机来找。
数字
在无头 Chromium(ANGLE/Metal)里以 1280 × 720 测量,每帧之后用 readPixels 强制一次 GPU 同步,两边跑同一份 972 帧的调度。
const px = new Uint8Array(4);
window.__sync = () => gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
| A · Blender 导出 | B · Three.js | 比值 | |
|---|---|---|---|
| 单文件体积 | 3.48 MB | 0.70 MB | 5.0× |
| 几何数据 | 2.57 MB(gzip 后) | 0,完全程序化 | — |
| Draw call | 91 | 918 | 10.1× |
| 三角形(该视角) | 278.5 k + 人物批次 | 487.1 k | — |
| 帧时间,均值 | 3.66 ms | 7.48 ms | 2.04× |
| 帧时间,p50 / p95 | 3.40 / 5.60 ms | 7.40 / 10.60 ms | — |
| 首帧时间 | 1.3 – 1.6 s | 0.83 s | 1.8× |
| 人物 | 78(21 个在走) | 约 13 | 6× |
| 场景源码 | 约 3,100 行 Python | 约 1,100 行 JS | 2.8× |
| 改一处到看见结果 | 约 5 分钟 | 49 秒 | 6× |
A 的三角形数取自导出清单里的静态部分,人物批次单独计(整个场景约 456 k);B 的取自该帧视锥剔除之后的 renderer.info。
最后一行可能是这张表里最该看的一条。A 的一轮迭代要五分钟,B 要 49 秒。 在一个下午里,这是十几次调整和上百次调整的区别,而画面的质量基本由迭代次数决定。
至于帧时间那两行:它干净地说明的只有导出这一步的价值。烘焙成二进制之后可以把一切合并成 91 个材质批次,光这一项就买下了两倍帧时间里的大部分。B 用 918 个 draw call 换来的,是小五倍的体积和运行时自我生成的能力。
那么,该怎么选
按需求倒推,这次实验给出的分界线还算清楚:
要内容量、要离线画质、要一张图或一段片子——从 Blender 开始。 上限在那里,全局光、体积散射、真实玻璃都能用上,面数不用算。代价是这些东西留在本地,网页上拿到的是它们的影子。
要交付一个能在浏览器里走进去的空间——从 Three.js 开始。 你审的那一版就是用户看到的那一版,中间没有导出这一步会丢东西。它的限制会逼着你把「阳光落在地上」这种句子翻译成明确的构造决定,而这种翻译往往就是画面成立的原因。
近景承载文字的东西,两边都用画布贴图。 一个用几何体做的表盘,在所有真正要紧的距离上都输给一张 512² 的画布。
无论选哪一边,都要有一套不属于这个场景的摄影机。 上面每一条结论,都来自至少有一边从没取过的机位。拿一个场景对着它自己整理好的镜头表做检查,等于只检查了那些你早就知道没问题的角度。
The question was specific: give the same brief to the same model (Claude Code, Opus 5), have it drive the Blender CLI and export to the web on one side and write three.js directly on the other, and see where the results actually differ — in capability, and in what ends up on screen.
This is not an engine benchmark. The two toolchains render in fundamentally different ways, so comparing frame rates proves nothing. What is worth knowing is where to start next time a brief asks for a space you can walk into in a browser.
Both sides got the same one-sentence brief: a 64 × 30 × 20 m barrel-vaulted terminal concourse at a quarter past nine on a clear morning, sun coming down through the roof glazing, a split-flap departures board as the focal point.
A · Blender was generated end to end by the model writing Python and driving the Blender CLI. Every shape comes from primitives, every material is a procedural node graph, and there are no purchased models and no image textures. It bakes into a compact binary — int16 positions, int8 normals — that a hand-written WebGL2 viewer renders inside a single self-contained HTML file. The process on this side draws on GLM 5.3’s write-up of building a scene in Blender.
B · three.js was written directly as scene code. Its geometry is generated at runtime and nothing is baked. It was built later, with A as a visual reference, and even its palette was ported over as linear albedo values, so the two should agree on colour. The source for B is in Makone.
Both sides are n = 1, same author, same model. Every conclusion below should be read against that sample size.
A high ceiling and a shippable result are two different things
Blender’s ceiling is far higher: global illumination, volume scatter, glass with real refraction, effectively no triangle budget. All of it holds up in an offline render.
None of it survived the export. What reaches the web is one directional light plus hemisphere ambient, a single 2048² shadow map, and a little distance fog. The Cycles volume scatter is not in there. The refraction is not in there. Geometry and textures are what crosses.
The three.js side knew from its first line of code that it had none of this. Everything has to be faked inside a 60 fps budget — and that constraint turns out to be its biggest advantage.
Between “can be made” and “can be delivered” sits an export step. That is the most expensive single finding in the experiment.
Don’t screenshot — put both on the same camera
Grabbing a few stills from each and comparing them by eye only produces two sets of pictures that each look fine on their own. So both pages received the same patch: hide every piece of interface, kill the built-in requestAnimationFrame loop, and expose one minimal external interface.
window.__api = {
set(eye, at, fovDeg) { // Blender coordinates in
cam.position.set(eye[0], eye[2], eye[1]);
cam.up.set(0, 1, 0);
cam.lookAt(at[0], at[2], at[1]);
cam.fov = fovDeg;
cam.updateProjectionMatrix();
},
step(dt) { world.renderFrame(dt); },
caption(s) { document.getElementById('cap-shot').textContent = s; },
};
The shot list is authored once, in Blender’s convention — X right, Y depth, Z up. The three.js side remaps to (x, z, y) inside its own set(). Both engines take a vertical field of view, so the framing is identical by construction rather than by adjustment.
window.SHOTS = [
{ id:'long-axis', cn:'长轴 · 西入口',
eye:[-29.0, 0.40, 1.68], at:[24.0, -1.0, 6.40],
fov:56, dolly:3.2, drift:[0, 0, 0.10] },
{ id:'clock', cn:'四面钟特写',
eye:[ -3.6, 3.60, 4.55], at:[ 0.0, 0.0, 4.33],
fov:34, dolly:0.9, drift:[0, 0, -0.10] },
// …nine viewpoints in all, 4.5 seconds each
];
window.FPS = 24;
window.TOTAL_FRAMES = window.SHOTS.length * 108; // 972
Capture is frame-stepped rather than screen-recorded: set the camera, render exactly one frame with dt = 1/24, screenshot, repeat.
for (let i = 0; i < total; i++) {
await page.evaluate((i) => {
const c = window.camAt(i);
window.__api.set(c.eye, c.at, c.fov);
window.__api.step(1 / window.FPS);
}, i);
await page.screenshot({ path: `${out}/${String(i).padStart(4, '0')}.png` });
}
ffmpeg -framerate 24 -i frames/a/%04d.png -framerate 24 -i frames/b/%04d.png \
-filter_complex "[0:v]pad=1280:724:0:0:color=0x2a3138[t];[t][1:v]vstack=inputs=2" \
-crf 18 compare.mp4
Deterministic stepping is what makes this worth doing. Real-time recording gives two clips that slowly drift apart; stepping gives two strictly aligned columns of 972 frames that can be scrubbed one frame at a time.
The first frame: the two halls are mirror images
Both builds use the same zone convention — gates and platforms on one side, retail arcade and mezzanine on the other, the great window at the east end. Each is internally consistent.
But mapping Blender’s (x, y, z) to three.js’s (x, z, y) is a reflection, and both projects had that mapping baked into their own declared viewpoints. The result: standing at the west entrance facing the great window, A’s arcade is on your left and B’s is on your right.

Same convention, opposite handedness. Eye, target, and vertical field of view are identical in the two frames.
No camera choice fixes this. B is the mirror of A. Flipping the rendered image would correct the chirality and mirror every piece of type in the room along with it. Actually aligning them means changing B’s construction code or A’s export axis convention. Both looked correct in isolation for as long as they existed; one shared rig surfaced it in the first frame.
On sheer content, Blender wins and it isn’t close
A is visibly fuller. Shopfronts with real signage, a ticket hall with numbered windows, luggage, trolleys, bins, stanchion queues, signage totems, trains sitting at the platforms, and people actually seated at the mezzanine café.
B is sparser and its furniture is larger: benches, bins, a few trees, a kiosk, stanchions. The arcade’s shop openings are empty. The mezzanine has tables and nobody at them.

Same viewpoint, roughly twice the stuff in frame above. But the floor tells the opposite story.
Crowd is the widest single gap. A carries 78 figures with varied clothing colours, 21 of them walking a corridor grid that runs between the bench rows so nobody strides through furniture. Per-figure state — position, yaw, gait phase — lives in a two-row RGBA32F texture that the vertex shader reads with texelFetch. Limbs are tagged per vertex at export time from each figure’s own local frame, so the stride costs one rotation in the shader and carries no skinning data at all. They cluster in front of the departures board the way people do.
B has around 13 actors on scripted routines: uniform pale mannequins, thinly spread.
On how the picture reads, three.js wins — because it can’t fake it
This was the surprise, and the reason for it is the constraint itself.
The brief asks for distinct bands of sun lying across the floor. Across all nine viewpoints, only B actually shoots that sentence.
A builds a full glazed truss vault — ribs, purlins, cross-bracing, the whole steel net. Sun passes through the entire glazed surface, so what lands on the floor is diffuse light chopped up by the grid. The light was handed to the solver, and the export came back flat and even.
B cannot do volumetrics, so it had only one route left: make the roof opaque and cut three longitudinal glazed bands into it, at roughly 25%, 48%, and 71% of the arc. The opaque part casts shadow and the glass does not, so the sun draws its own hard-edged stripes across the floor. The visible shafts are geometry — one merged additive prism per band per bay — with dust motes seeded inside the beams rather than scattered through the room.

This one frame explains every lighting difference that follows. The upper roof is a net; the lower one is a slab with three slots cut in it.
| A · Blender export | B · three.js | |
|---|---|---|
| Shading | forward: 1 directional + hemisphere ambient, single 2048² shadow map, distance fog, Reinhard-ish compression | ACES filmic, exposure 0.86 |
| Lights | sun + hemi | hemi 0.55, ambient 0.13, directional 2.35, plus 2 RectArea and 1 Point fill |
| Volumetrics | none — the Cycles volume scatter did not survive the export | shaft geometry + drifting dust particles |
| Reads as | flat, cool, evenly lit, almost no shadow | warm, high contrast, real dark corners |
A has lighting; B has a lighting idea. The renderer that can’t fake it makes you decide, and deciding is most of art direction.
Push in, and the verdict flips again
The four-face clock is the hero prop of this room. At 4.5 m the judgement reverses completely.

A’s four columns align better and the tracking is more even, but it is dead; B’s board has status colour, and one row physically flips every 2.2 seconds.
A’s dial has no numerals. The source does create text objects for them on all four faces, but they are absent from the WebGL export and from the offline Cycles renders, so the scene lost them during the build rather than at the export. I re-shot the clock from all four azimuths in case it was backface culling; they are simply not there. The hands are two boxes and the hub is a cylinder.
B’s dial is a CanvasTexture — Roman numerals, minute ticks, tapered hands, a brass bezel with a real specular, and hands that advance. At 4.5 m it is still a clock.

A dial built from geometry loses to a 512² canvas at every distance that matters, and it spends more triangles doing it.
The departures board tells the same story. A’s typography is finer — four aligned columns, even tracking, set as actual geometry — but it never updates. B’s is a CanvasTexture with status colour (on time / boarding / delayed / platform change), and one row flips every 2.2 seconds over 0.45 s. It is the only thing in the room carrying news.
Density wins far and loses near.
Both builds hide errors only a new camera can find
The last viewpoint looks back down the long axis from the east end — an angle neither project’s own shot list contains. That is the entire point of a shared rig.

B’s board was only built on the face meant to be read; the back was never made. A’s board holds up from either side.
Sorting A’s 18 logged defects by kind says more than counting them. Ten are pure tool tax — node graphs, the export pipeline, axis conventions — and cannot exist in three.js at all. The other eight are spatial: handedness, single-sided geometry, angles nobody framed. Those survive any change of renderer.
Choosing a tool saves you the first ten. The other eight only turn up under a camera that belongs to neither build.
Numbers
Measured in headless Chromium (ANGLE/Metal) at 1280 × 720, with readPixels forcing a GPU sync after every frame, both sides running the same 972-frame schedule.
const px = new Uint8Array(4);
window.__sync = () => gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
| A · Blender export | B · three.js | ratio | |
|---|---|---|---|
| Single-file size | 3.48 MB | 0.70 MB | 5.0× |
| Geometry payload | 2.57 MB gzipped | 0 — fully procedural | — |
| Draw calls | 91 | 918 | 10.1× |
| Triangles (this view) | 278.5 k + figure batches | 487.1 k | — |
| Frame time, mean | 3.66 ms | 7.48 ms | 2.04× |
| Frame time, p50 / p95 | 3.40 / 5.60 ms | 7.40 / 10.60 ms | — |
| Time to first frame | 1.3 – 1.6 s | 0.83 s | 1.8× |
| Figures | 78 (21 walking) | ~13 | 6× |
| Scene source | ~3,100 lines Python | ~1,100 lines JS | 2.8× |
| Edit to seeing the result | ~5 min | 49 s | 6× |
A’s triangle count is the static figure from its export manifest, with figure batches counted separately (~456 k for the whole scene). B’s is renderer.info for that frame, after frustum culling.
That last row may be the one to read first. One round trip costs A five minutes and B 49 seconds. Over an afternoon that is the difference between a dozen adjustments and a hundred, and picture quality is mostly decided by how many rounds you get.
As for the frame-time rows: what they show cleanly is the value of the export step. Baking to a binary lets you merge everything into 91 material batches, and that alone buys most of the 2× frame time. B pays 918 draw calls for the privilege of being five times smaller and generating itself at runtime.
So which one do you pick
Working backwards from the brief, the dividing lines come out reasonably clear:
For content volume, offline quality, a still or a film — start in Blender. The ceiling is there: global illumination, volume scatter, real glass, no triangle budget. The cost is that those things stay on your machine, and the web gets their shadow.
To deliver a space someone walks into in a browser — start in three.js. The version you reviewed is the version they see; there is no export step in between to lose things. Its limits force sentences like “sun lying across the floor” into explicit construction decisions, and that translation is often the reason the picture works at all.
Anything close-up that carries type: use a canvas texture on either side. A dial built from geometry loses to a 512² canvas at every distance that matters.
Whichever side you pick, get a camera that belongs to neither. Every finding above came from a viewpoint that at least one of the two builds had never framed. Reviewing a scene against its own curated shot list means reviewing it against the angles you already know are fine.