前段时间苹果发布会上看到iPhone Duo的开屏动画很是惊艳。iPhone Duo 的开屏动画,其核心是一种渐进式模糊与虚化过渡效果。当你展开或折叠手机时,外屏的画面会自然地逐渐模糊,仿佛消融并“融化”进内屏,而不是生硬地切换。这个过程就像一个光学错觉,让你感觉像是透过一扇透明的窗户在看屏幕,两个屏幕在视觉上被无缝连接成一个整体,从而彻底消除了其他折叠屏手机常见的画面跳跃感和割裂感。
1. 总览:角度怎样变成画面
滑块 / 播放 / 过渡
→ setAngle(angle°)
→ bend.value = foldAngle(弧度)
→ 每帧 renderer.render
├─ 顶点 fold-*.glsl:机身变形
└─ 片元 screen-*.glsl:投影 UV → 壁纸采样 → 模糊 / 压暗
setAngle 不画图,只改状态;真正出画面是动画循环末尾的 render。
2. 角度写入:setAngle
function setAngle(value) {
angle = value
bend.value = ((180 - value) / 180) * Math.PI
if (screens.outer?.material) {
screens.outer.material.color.setScalar(value >= 180 ? 0 : 1)
}
hooks.onAngle?.(value)
}
| 语句 | 含义 |
|---|---|
angle = value | JS 侧记录界面角度(度):180 展开,0 合上 |
bend.value = … | 写成 GPU 用的折叠角(弧度):展开→0,合上→π。bend 在首次编译时已赋给 shader.uniforms.foldAngle,二者是同一对象,所以调角度不会再跑 onBeforeCompile,只改 .value |
外屏 color.setScalar | 完全展开时外屏乘 0(关掉),避免背面外屏还亮着 |
hooks.onAngle?.(value) | 回调给 Vue 同步滑块显示;不参与 WebGL 绘制 |
对应关系:
界面滑块 angle | 含义 | GPU foldAngle(bend.value) |
|---|---|---|
| 180° | 展开 | 0 |
| 90° | 半开 | π/2 |
| 0° | 合上 | π |
公式:bend.value = ((180 - angle) / 180) * Math.PI。滑块越大越开,foldAngle 越小(方向相反)。
3. 每帧循环:谁在改角度、谁在画
renderer.setAnimationLoop((now) => {
// …
if (ready && playing) {
phase = (phase + delta) % 8.6
// 分段算出 value → setAngle(value)
} else if (transition) {
// smoothstep 插值 → setAngle(...)
}
controls.update()
renderer.render(scene, camera)
})
优先级:
- 播放中:8.6s 一圈——展开停留 → 余弦合上 → 合上停留 → 余弦打开,每帧
setAngle - 有过渡(如自定义图):1.4s smoothstep 收到目标角
- 否则:角度保持上次(用户拖滑块时已在外部调过
setAngle) - 最后
controls.update()+renderer.render(...)—— 着色器读当前bend.value画出这一帧
delta 用 Math.min(..., 0.05) 钳制,避免切后台回来一帧跳太大。
4. 固定眼睛与 UI 矩形(展开态标定)
const uiReferenceEye = new THREE.Vector3(0, 0, 40)
const innerUIFrame = new THREE.Vector4(-7.89935, 0.34562 - 5.8974, 15.7987, 11.1035)
const outerUIFrame = new THREE.Vector4(0.23396, 0.27173 - 5.8974, 7.73936, 11.2513).multiplyScalar(
(uiReferenceEye.z - 0.24948) / (uiReferenceEye.z - 0.825538),
)
uiReferenceEye:屏幕投影用的固定眼睛,与 Orbit 相机解耦。转视角只改变你怎么「看手机」,不改变 UI 按正面投影采样的方式。
innerUIFrame / outerUIFrame:Vector4(x, y, 宽, 高),表示完全展开时 UI 矩形在模型坐标里的位置与大小。片元里:
sourceUV = (projected - uiFrame.xy) / uiFrame.zw
它们是校准常数,折叠时不改矩形本身(只 copy 进 screens[].frame);变的是几何折起来 + foldAngle。外屏乘深度比例,是为了外屏物理深度不同时,投影尺度仍和内屏对齐。
这里的 (0,0) 是模型坐标原点(铰链对齐后),不是网页横竖中线交点。手机看起来在画面中央,是因为相机对着机模中心构图。
4.1 模型坐标系:先分清 X / Y / Z
读 fold-* / screen-* 着色器前必须先固定坐标语义,以下结论全部可从代码验证:
| 轴 | 物理含义 | 代码证据 |
|---|---|---|
| X | 展开后的宽度方向。盖板半机在 −X,固定半机在 +X,铰链在 x = 0 | bendStrip 按 p.x 分三段(±0.35) |
| Y | 手机高度方向,折叠完全不碰它 | transformed = vec3(folded.x, position.y, folded.y)——y 原样穿透 |
| Z | 厚度 / 屏幕法线,+Z 朝观察者。内屏 z ≈ +0.249,外屏玻璃 z ≈ −0.275,铰链轴 z = +0.2755 | 固定眼睛 (0, 0, 40) 沿 −Z 看屏幕 |
5. 屏幕状态:screens 与主题
初始化时为内外屏各建一份状态:
screens[kind] = {
material,
defaultTextures,
frame: { value: (kind === 'inner' ? innerUIFrame : outerUIFrame).clone() },
gradient: { value: new THREE.Vector2(kind === 'inner' ? 0.5 : 0, kind === 'inner' ? 0 : 1) },
pixel: {
value: new THREE.Vector2(
1 / defaultUIs[uiTheme][kind].width,
1 / defaultUIs[uiTheme][kind].height,
),
},
}
| 字段 | 作用 |
|---|---|
material | 屏幕材质,map 即当前 UI 贴图 |
defaultTextures | { wallpaper, launcher },切换预置主题时直接换 |
frame | 投影矩形,挂到 shader.uniforms.uiFrame(引用) |
gradient | 模糊扫过方向:内屏 (0.5, 0),外屏 (0, 1) |
pixel | (1/宽, 1/高),模糊核采样步长 |
挂到 shader 时同样是引用:
shader.uniforms.foldAngle = bend
// …
shader.uniforms.uiFrame = screens[kind].frame
shader.uniforms.uiGradient = screens[kind].gradient
shader.uniforms.uiReferenceEye = { value: uiReferenceEye }
shader.uniforms.uiPixel = screens[kind].pixel
之后 showDefaultUI 改 frame.value / gradient.value,下一帧自动生效,无需重编译。
预置主题如何挂上
function showDefaultUI() {
for (const [kind, screen] of Object.entries(screens)) {
const texture = screen.defaultTextures[uiTheme]
screen.material.map = texture
screen.pixel.value.set(1 / texture.image.width, 1 / texture.image.height)
screen.frame.value.copy(kind === 'inner' ? innerUIFrame : outerUIFrame)
screen.gradient.value.set(kind === 'inner' ? 0.5 : 0, kind === 'inner' ? 0 : 1)
}
}
function setTheme(theme) {
if (theme === 'custom') return
uiTheme = theme
showDefaultUI()
}
loadDefaultUIs()(见下一节)负责造图;showDefaultUI()负责挂到材质并刷新 uniform。theme === 'custom'直接 return:自定义图不在defaultTextures里,必须走applyCustomImage再选文件。
自定义图:uiCanvas + uiTexture
let uiTheme = 'wallpaper'
const uiCanvas = document.createElement('canvas')
uiCanvas.width = 1600
uiCanvas.height = 1125
const uiTexture = new THREE.CanvasTexture(uiCanvas)
| 作用 | |
|---|---|
uiCanvas | 2D 画布,drawImage 用户图 |
uiTexture | 把该 canvas 当 WebGL 贴图,material.map = uiTexture |
默认壁纸 / 桌面不用这一对,只用 defaultTextures。上传成功后 uiTheme = 'custom',并 transition 收到 180°。
6. 默认 UI 合成:loadDefaultUIs
export async function loadDefaultUIs() {
// 加载 wallpaper / clock / launcher 图片
// 离屏 canvas 合成 themes.wallpaper|launcher × inner|outer
return themes
}
从 public/assets/ui/ 读图(壁纸、时钟、桌面),画成 4 张 canvas 返回。
注意:这里的 ui/ 是屏幕内容;机身外壳贴图在 public/assets/textures/,由 USD 加载时自动绑定,两者不是一回事。
7. 机模加载:USDLoader 与网格分类
const model = await new USDLoader().loadAsync('/assets/iPhone_Duo_Render.usdc')
model.scale.multiplyScalar(100)
model.updateMatrixWorld(true)
USDLoader:把 USDC 机模读进 Three.js(网格、材质、节点名),并按引用加载 textures/。类似 glTF 的 GLTFLoader,只是格式是 USD。
7.1 traverse 里的 object 是什么?
model.traverse((object) => {
if (!object.isMesh) return
object 是场景树里扫到的每个对象;过滤后只处理 Mesh——机模里的一块零件(有几何、材质、object.name)。
一整个手机通常有二三十个 mesh(镜头、按键、中框、内外屏、铰链件等),所以 console.log(object) 会打出很多名字,这是正常的。
object.name 是 USD 导出时的节点 ID(乱码式字符串),换模型后会变。
这些名字的来源(具体文件):
-
源头:
app/public/assets/iPhone_Duo_Render.usdc
USD Crate 二进制里每个 prim(节点)自带名字。直接在文件字节里能扫到upTUAKvMVkPOMKq、SiftyleUEEZwLhF、JnJdTkxbQgUtLwU、UXtsBZYlaUvHoEh等,与SkelRoot/Mesh/MaterialBindingAPI等 USD 保留字相邻——它们就是机模的 prim 命名。 -
解析赋值:
node_modules/three/examples/jsm/loaders/usd/USDComposer.js
加载器逐 prim 构建 Object3D 时把名字搬上去(obj.name = name)。名字不是 JS 定义的,是文件里带的。 -
消费:
app/src/lib/duoScene.js(traverse 白名单比对)
命名本身是当年制作机模的 DCC 工具 / 导出脚本生成的随机安全名,无语义;参考项目作者也是读出后硬编码进 main.js,本项目移植时沿用同一套 ID。因此换机模文件,白名单必须重新核对。
7.2 两个半机祖先:upTUAKvMVkPOMKq / SiftyleUEEZwLhF
// 祖先 upTUAKvMVkPOMKq → 盖板侧(moving)
// 祖先 SiftyleUEEZwLhF → 后摄侧(fixed,不折)
let ancestor = object
while (ancestor && !['upTUAKvMVkPOMKq', 'SiftyleUEEZwLhF'].includes(ancestor.name)) {
ancestor = ancestor.parent
}
const moving = ancestor?.name === 'upTUAKvMVkPOMKq'
这两个不是「左右两条金属边框」,而是 USD 里两半机的父节点(分组根):
| 名字 | 含义 |
|---|---|
upTUAKvMVkPOMKq | 盖板半机整棵子树(外屏那一边) |
SiftyleUEEZwLhF | 后摄半机整棵子树(固定、带后摄那一边) |
往上找祖先,是为了判断当前零件属于「会翻的一半」还是「不动的一半」。
是否包含屏幕? 会。屏幕是挂在半机下面的子 mesh,不是这两个字符串本身:
- 盖板半机下常见:外屏
hhgAIoCGsHXeDPY、盖板金属壳等 - 后摄半机下常见:壳、镜头等
- 内屏
UXtsBZYlaUvHoEh:以柔性名单为准走bendStrip;层级上通常也挂在某一半下面
7.3 柔性名单:为什么只有 4 个,不是全部 mesh?
const flexible = ['JnJdTkxbQgUtLwU', 'xdyyaajWsatVNxN', 'UXtsBZYlaUvHoEh', 'MvKPXGSdYDVvSpk'].includes(
object.name,
)
这是白名单:只有要用中缝弯曲(bendStrip)的零件才写进来,不是「所有 object.name 的完整列表」。
| 名字 | 角色 |
|---|---|
UXtsBZYlaUvHoEh | 内屏(同时 kind === 'inner') |
JnJdTkxbQgUtLwU、xdyyaajWsatVNxN、MvKPXGSdYDVvSpk | 中缝附近其它会弯的结构件 |
你打印到的其它名字(例如 NtYMsWnAcbWQvBP)代码里没写,说明它不是柔性件:要么在后摄半机上固定不动,要么在盖板半机上走刚体翻转。可用下面方式确认归属:
console.log(object.name, { moving, flexible, ancestor: ancestor?.name })
7.4 moving / flexible / 屏幕 kind
const kind =
object.name === 'UXtsBZYlaUvHoEh' ? 'inner' : object.name === 'hhgAIoCGsHXeDPY' ? 'outer' : null
| 类型 | 判定 | 行为 |
|---|---|---|
| 后摄侧 fixed | 祖先是 SiftyleUEEZwLhF,且不在柔性名单 | 不注入 fold,原地不动 |
| 盖板 moving(刚体) | 祖先是 upTUAKvMVkPOMKq,且不在柔性名单 | fold-cover.glsl:rotateHinge |
| 柔性 flexible | 名字在 4 个白名单里 | fold-flexible.glsl:bendStrip |
| 屏幕 kind | 内屏 / 外屏专用名 | 再挂 screen-*.glsl |
视觉对应(便于对照演示画面):
- 刚体:外屏那一半上的金属壳体 / 边框等——形状不变,整块绕铰链翻;不包括外屏画面本身。
- 柔性带:展开后内屏中间开合缝那一条(
|x| < 0.35做 Hermite 弯);不是上下铰链装饰框 alone,也不是合上侧视时那条不锈钢亮边本身。 - 外屏画面:盖板侧 mesh
hhgAIoCGsHXeDPY,折的同时走screen-outer.glsl。 - 内屏画面:
UXtsBZYlaUvHoEh,柔性 +screen-inner.glsl。
7.5 if (moving || flexible):谁要注入折叠着色器?
if (moving || flexible) {
只有「盖板半机上的件」或「柔性白名单里的件」才进 onBeforeCompile 挂 fold。
后摄半机固定件两边都不进,几何原样。
内部再选 GLSL:
const foldGlsl = flexible ? foldFlexibleGlsl : foldCoverGlsl
柔性 → 中缝弯;否则盖板刚体 → 壳体硬翻。
7.6 屏幕重建 UV(不是模糊)
if (kind) {
// 按位置算 uv[i*2], uv[i*2+1]
geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
}
只给屏幕 mesh 建基础 UV。壁纸怎么糊、黑角怎么来,全在 screen-*.glsl。
8. onBeforeCompile:何时注入、和每帧渲染的关系
if (moving || flexible) {
material.onBeforeCompile = (shader) => {
shader.uniforms.foldAngle = bend
// 屏幕则再挂 uiFrame / 注入 screen-*.glsl
// 顶点插入 fold-*.glsl,替换 begin_vertex / beginnormal_vertex
}
material.customProgramCacheKey = () => /* fold-flexible|cover + kind */
}
- 时机:材质第一次被
render画到时(或 cache key 变了重编译),在renderer.render内部触发。 - 不是每帧在
controls.update前后跑一遍。 - 注入后:顶点负责折,片元负责屏;之后只靠改
bend.value等驱动动画。
customProgramCacheKey 区分柔性 / 盖板 / 是否屏幕,避免 Three.js 串用同一套已编译程序。
9. 顶点折叠:fold-*.glsl
盖板刚体(fold-cover.glsl):rotateHinge——外屏半机金属壳等,XZ 平面绕铰链转 foldAngle,形状不变。
柔性(fold-flexible.glsl):bendStrip——中缝 |x|<0.35 Hermite 弯;两侧分别接固定半机 / 盖板。
折叠只改形状;屏幕上画什么由片元决定。
9.1 fold-cover.glsl 全文(盖板刚体)
只有一个函数 rotateHinge(p),处理 XZ 平面:
// 盖板刚体:绕铰链轴旋转(XZ 平面)
// uniform foldAngle: 0 = 展开, PI = 合上
uniform float foldAngle;
vec2 rotateHinge(vec2 p) {
float c = cos(foldAngle);
float s = sin(foldAngle);
p.y -= 0.275454; // 1) 平移到铰链轴(旋转中心)
return vec2(
c * p.x + s * p.y, // 2) 按 foldAngle 二维旋转
-s * p.x + c * p.y + 0.275454 // 3) 转完加回平移
);
}
流程:p.y -= 0.275454(把旋转中心挪到铰链轴)→ 二维旋转矩阵 → 加回 0.275454。整块刚体绕轴转,形状不变。
9.2 fold-flexible.glsl 全文(柔性条带)
复用同一个 rotateHinge,核心是 bendStrip(p),返回 vec4(折后 xz, 切线 xy),按 p.x 分三段:
// 铰链柔性条带:中间 Hermite 弯曲,两侧接刚体盖板 / 固定半机
uniform float foldAngle;
vec2 rotateHinge(vec2 p) { /* 与上完全相同 */ }
vec4 bendStrip(vec3 p) {
float halfWidth = 0.35;
// 段 1:固定半机侧,不变形
if (p.x >= halfWidth) {
return vec4(p.x, p.z, 1.0, 0.0);
}
// 段 2:盖板侧,整块跟铰链转
if (p.x <= -halfWidth) {
return vec4(rotateHinge(p.xz), cos(foldAngle), -sin(foldAngle));
}
// 段 3:中间条带,Hermite 样条
float t = (p.x + halfWidth) / (2.0 * halfWidth);
float t2 = t * t;
float t3 = t2 * t;
vec2 a = rotateHinge(vec2(-halfWidth, p.z)); // 左端点(旋转后)
vec2 b = vec2(halfWidth, p.z); // 右端点(固定)
vec2 ta = 2.0 * halfWidth * vec2(cos(foldAngle), -sin(foldAngle)); // 左端切线
vec2 tb = vec2(2.0 * halfWidth, 0.0); // 右端切线
// 位置 = 4 个 Hermite 基函数加权和(端点位置 + 端点切线插值)
vec2 point =
(2.0 * t3 - 3.0 * t2 + 1.0) * a +
(t3 - 2.0 * t2 + t) * ta +
(-2.0 * t3 + 3.0 * t2) * b +
(t3 - t2) * tb;
// 切线 = 同式对 t 求导,再归一化
vec2 tangent = normalize(
(6.0 * t2 - 6.0 * t) * a +
(3.0 * t2 - 4.0 * t + 1.0) * ta +
(-6.0 * t2 + 6.0 * t) * b +
(3.0 * t2 - 2.0 * t) * tb
);
return vec4(point, tangent);
}
三段总结:
| 区间 | 行为 | 切线 | ||
|---|---|---|---|---|
p.x >= +0.35 | 固定半机侧,原样返回 | (1, 0),法线不变 | ||
p.x <= -0.35 | 盖板侧,整块 rotateHinge 刚转 | (cos a, -sin a) | ||
| 中间 ` | x | < 0.35` | Hermite 样条连接两端 | 样条导数归一化 |
9.3 注入点:snippets.js 里顶点与法线的改写
两个文件只是「函数库」,真正调用它们的是 onBeforeCompile 替换的三个 include 位点:
// 顶点位置:盖板刚体
export const BEGIN_VERTEX_COVER =
'vec2 folded = rotateHinge(position.xz);\n' +
'vec3 transformed = vec3(folded.x, position.y, folded.y);\n'
// 顶点位置:柔性
export const BEGIN_VERTEX_FLEXIBLE =
'vec4 folded = bendStrip(position);\n' +
'vec3 transformed = vec3(folded.x, position.y, folded.y);\n'
// 法线:盖板用同一角度直接旋转
export const BEGIN_NORMAL_COVER =
'vec3 objectNormal = vec3(normal);\n' +
'float a = foldAngle;\n' +
'objectNormal.x = cos(a) * normal.x + sin(a) * normal.z;\n' +
'objectNormal.z = -sin(a) * normal.x + cos(a) * normal.z;\n'
// 法线:柔性由 bendStrip 返回的切线反求角度
export const BEGIN_NORMAL_FLEXIBLE =
'vec3 objectNormal = vec3(normal);\n' +
'vec4 strip = bendStrip(position);\n' +
'float a = atan(-strip.w, strip.z);\n' + // 切线 → 局部折叠角
'objectNormal.x = cos(a) * normal.x + sin(a) * normal.z;\n' +
'objectNormal.z = -sin(a) * normal.x + cos(a) * normal.z;\n'
汇合点:两条路最后都得到 transformed = vec3(折后 x, position.y, 折后 z),再经 project_vertex 投影并写出 vUIPosition,交给 §10 的 screenColor() 做屏幕取色——折叠变形与屏幕投影正是通过这个 varying 串联的。
补充说明:
- 两个文件各带一份相同的
rotateHinge,按文件各自拼接进 shader,互不影响。 - 弯曲只发生在 x 方向,高度 y 不变。
screen-outer.glsl里也有一个rotateHinge,但它不变形顶点,只折「铰链边一个点」来算anchorX(见 §10.5)。
10. 片元:投影、模糊、黑三角(以 screen-inner.glsl 为例)
壁纸能显示、折叠变糊变黑,主要靠这套逻辑。
10.0 screenColor() 总流程(内外屏共用骨架)
screenColor() 每个屏幕片元执行一次(vUIPosition = 折叠变形后的位置)
│
├─ 1) 固定正视投影
│ 眼睛 (0,0,40) → 片元打射线
│ 与展开平面 z=0.24948 求交 → projected
│ sourceUV = (projected - frame.xy) / frame.zw
│
├─ 2) 屏幕差异步骤(内屏 / 外屏不同,见 §10.5 / §10.6)
│ 内屏:无额外步骤,progress = foldAngle / (π/2),越折越糊
│ 外屏:先折铰链边点投影得 anchorX 卷 UV.x;
│ progress = (π - foldAngle) / (π/2),越开越糊
│
├─ 3) 横向梯度 → 两个强度
│ edge:UV.x 相对 gradient 区间的进度
│ radius = 72 × motion × blur^1.35 ← 模糊半径
│ effect = motion × darken^1.35 ← 压暗强度
│
├─ 4/5) 分支采样
│ radius ≤ 0 → 单次 textureLod(清晰区)+ coverage 抗锯齿
│ radius > 0 → 5×5 加权核 + 提高 mip(模糊区),
│ coverage 一同扩散 → 黑边渗入画面
│
└─ 6) 压暗收尾
return color × (1 - min(1, effect × 2))
一句话:投影取 UV 相同 → 内屏「直接用 + 越折越糊」,外屏「先锚定铰链边卷 UV + 越开越糊」→ 同一套 5×5 模糊 + 压暗收尾。
10.1 固定正视投影 → UV
float depth = (0.24948 - uiReferenceEye.z) / (vUIPosition.z - uiReferenceEye.z);
vec2 projected = uiReferenceEye.xy + (vUIPosition.xy - uiReferenceEye.xy) * depth;
vec2 sourceUV = (projected - uiFrame.xy) / uiFrame.zw;
几何已经折弯,但贴图仍按「展开平面」正视采样,图案不会贴在曲面上扭歪。
10.2 开合进度 + 横向梯度
float progress = clamp(foldAngle / 1.570796327, 0.0, 1.0);
float edge = (sourceUV.x - uiGradient.x) / (uiGradient.y - uiGradient.x);
float motion = smoothstep(0.0, 1.0, progress);
float blurGradient = clamp(edge, 0.0, 1.0);
float darkenGradient = clamp((edge - 0.2) / 0.8, 0.0, 1.0);
float effect = motion * pow(darkenGradient, 1.35);
float radius = 72.0 * motion * pow(blurGradient, 1.35);
内屏 uiGradient = (0.5, 0):edge 从 UV.x=0.5(中缝,不糊)扫向 0(更糊)。
真正糊多少还要乘 progress(内屏越折越糊)。
10.3 采样与 5×5 模糊
// coverage:UV 在 [0,1] 内才有颜色,出界当黑
vec3 color = textureLod(...).rgb * coverage.x * coverage.y;
if (radius > 0.0) {
// 5×5 加权核;coverage 一起模糊 → 黑边渗进画面
}
10.4 压暗与「黑色三角」从哪来
return color * (1.0 - min(1.0, effect * 2.0));
折叠时边上的黑色三角 / 楔形,不是另画的三角面片,而是叠出来的:
- 上式铰链侧压暗
- UV 出界
coverage≈0当黑,模糊后渗到角上 - 折弯后投影对不齐,贴图盖不满
- 机身铰链厚度等实心几何
10.5 screen-outer.glsl 与内屏的差异
外屏与内屏第 1 / 3 / 4 / 5 / 6 步完全一致,差异集中在第 2 步(进度方向 + UV 锚定)。完整源码见 app/src/lib/shaders/screen-outer.glsl;图钉 / foldedEdge 细节见 §10.6。
内屏 screen-inner | 外屏 screen-outer | |
|---|---|---|
| 第 2 步 | 无(sourceUV 直接用投影结果) | 折铰链边点 → 投影得 anchorX → 卷 UV.x |
progress | foldAngle / (π/2):越折越糊 | (π - foldAngle) / (π/2):越开越糊、合上清晰 |
gradient | (0.5, 0):模糊从中缝扫向一侧 | (0, 1):从铰链边扫向另一侧 |
| anchorX 目的 | — | 让外屏 UI 贴着折过去的铰链边,合上时内容不错位 |
补充:外屏 shader 里的旋转只折「一个点」(hingeEdge),不变形顶点——顶点变形是 fold-*.glsl 的职责。完全展开(180°)时外屏整体乘黑由 JS 侧 setAngle 的 color.setScalar 完成,不在这个 shader 里。
10.6 外屏「图钉」详解:hingeEdge / foldedEdge 定位
外屏第 2 步本质是:在外屏靠铰链的边线上钉一颗图钉,算出手机折叠后这颗图钉甩到了哪里。
float c = cos(foldAngle);
float s = sin(foldAngle);
vec2 hingeEdge = vec2(-0.23396, -0.27463 - 0.275454);
vec2 foldedEdge = vec2(
c * hingeEdge.x + s * hingeEdge.y,
-s * hingeEdge.x + c * hingeEdge.y + 0.275454
);
float edgeDepth = (0.24948 - uiReferenceEye.z) / (foldedEdge.y - uiReferenceEye.z);
float anchorX = uiReferenceEye.x + (foldedEdge.x - uiReferenceEye.x) * edgeDepth;
sourceUV.x = uiGradient.x + (projected.x - anchorX) / uiFrame.z;
旋转公式与 §9.1 的 rotateHinge 同一套(减轴 → 转 → 加回),这里只转一个写死点、不变形顶点;差别仅是「减 0.275454」提前写进了 hingeEdge 常数。末尾 + 0.275454 是把结果从临时原点系还原回真实模型坐标。
foldedEdge 是啥?
foldedEdge = 外屏铰链侧边线上那颗图钉,按当前 foldAngle 折过去之后的位置(模型坐标 (x, z))。不是整条边,只是边上那一个点。
| 含义 | |
|---|---|
hingeEdge | 展开态的图钉(旋转输入点,相对铰链轴已做平移) |
foldedEdge | 盖板折了 foldAngle 之后,这颗图钉在空间里落在哪 |
foldAngle 与界面滑块 angle 的对应关系(与 §2 相同):
界面滑块 angle | 含义 | GPU foldAngle(bend.value) |
|---|---|---|
| 180° | 展开 | 0 |
| 90° | 半开 | π/2 |
| 0° | 合上 | π |
公式:bend.value = ((180 - angle) / 180) * Math.PI。下面只关心折后图钉落点:
foldAngle | foldedEdge (x, z) 大概在哪 | 物理画面 |
|---|---|---|
| 0(展开) | (-0.234, -0.275) | 仍在外屏边:图钉在原位 |
| π/2(立起) | (-0.550, +0.509) | 盖板竖起,图钉绕轴甩 1/4 圈 |
| π(合上) | (+0.234, +0.826) | 甩到正面——0.825538 正是 §11 的「外屏深度」常数 |
第三行自洽验证:合上时玻璃面落在 z = 0.8255(与 §4 outerUIFrame 缩放同源);x 镜像为 +0.234,边线离铰链仍是 0.234。
hingeEdge 两个分量(vec2 = (x, z),坐标系见 §4.1):
| 分量 | 值 | 拆解 | 含义 |
|---|---|---|---|
.x | -0.23396 | 单个数 | 外屏靠铰链那条边线的 x(屏幕与铰链桶留白)。量的是「边线 → 铰链轴 x=0」,不是到原点 |
.z | -0.550084 | -0.27463 + (-0.275454) | 外屏玻璃物理 z(背面)减去铰链轴 z;= 玻璃到轴 ≈ 半边机身厚 |
z 不能填 0:玻璃离轴隔着机身厚度。旋转半径 ≈ √(0.234² + 0.550²) ≈ 0.598;填 0 会导致合上时图钉深度错、anchorX 错、UI 与玻璃「脱胶」。
0.23396 三处咬合(同一物理边线,必须一致):
- UV 重建(§7.6):
uv.x = (-0.23396 - x) / 7.73936→ UV.x = 0 对应 x = -0.23396 - 外屏 UI 矩形(§4):
outerUIFrame.x = 0.23396 - 图钉:
hingeEdge.x = -0.23396
anchorX 干什么: 回答「UV.x = 0 那条边,现在正视下落在展开平面的 x = 多少?」——外屏每个片元的取色都从 anchorX 起量,UI 跟着盖板「卷」。内屏中缝固定在 x = 0,不需要这步。
11. 关键常数(与机模对齐)
| 常数 | 用途 |
|---|---|
0.275454 | 铰链轴 z 坐标(旋转前平移原点 / 轨道目标) |
0.24948 | 展开内屏平面深度(投影) |
-0.27463 | 外屏玻璃表面物理 z(展开、背面) |
-0.23396 | 外屏铰链侧边线 x 坐标(UV / UI 矩形 / 图钉共用的起算边) |
0.550084 | -0.27463 + (-0.275454):玻璃到铰链轴 z 向距离 ≈ 半边机身厚(图钉旋转半径的 z 分量) |
0.825538 | 合上后外屏玻璃深度 = 0.275454 + 0.550084(图钉 foldAngle=π 的 z 分量 / outerUIFrame 缩放) |
5.8974 | 模型 Y 平移 |
0.35 | 柔性带半宽 |
改 USDC 或缩放后需一起重测,否则铰链错位或 UI 漂移。
定位关系详见 §4.1(坐标系)、§10.6(外屏图钉)。
12. 对外 API
| 方法 | 作用 |
|---|---|
setAngle(deg) | 停播放、清过渡、写 bend |
togglePlay() | 自动开合;起播按当前角反算 phase,避免跳变 |
setTheme('wallpaper'|'launcher') | 换预置 UI |
applyCustomImage(file) | 自定义图并过渡到展开 |
dispose() | 停循环、释放 WebGL |
Hooks:onAngle(同步滑块)、onReady、onError。
13. 建议阅读顺序
setAngle+ 动画循环末尾renderfold-cover.glsl/fold-flexible.glsl(流程图:「fold 折叠变形流程」页)traverse里 moving / flexible / kindscreen-inner.glsl(再对比screen-outer.glsl;流程图:「screenColor 流程」页)loadDefaultUIs→showDefaultUI→applyCustomImage
对照:diagrams/duoScene-flow.drawio,共 7 页:
- 总览与初始化
- 网格分类与折叠
- 屏幕投影与模糊
- screenColor 流程(对应本文 §10)
- fold 折叠变形流程(对应本文 §9)
- 坐标系与铰链轴(对应本文 §4.1 / §10.6 / §11)
- 角度驱动与渲染
14. 附录 A:目录结构
14.1 源码 app/src/
src/
├── main.js 入口:createApp(App).mount('#app')
├── App.vue 根组件,只渲染 <DuoViewer />
├── style.css 全局样式(字体、reset、焦点样式)
├── assets/ Vite 模板静态资源(与 3D 无关)
├── components/
│ └── DuoViewer.vue 视图:viewport、滑块、播放、主题切换、生命周期
└── lib/
├── duoScene.js 场景主逻辑(本文件详解重点)
├── ui.js loadDefaultUIs:合成壁纸/桌面 canvas
└── shaders/
├── snippets.js onBeforeCompile 注入短字符串 + injectScreenShader
├── fold-cover.glsl 盖板刚体:rotateHinge
├── fold-flexible.glsl 柔性带:bendStrip(Hermite)
├── screen-inner.glsl 内屏片元:投影 + 进度模糊/压暗
└── screen-outer.glsl 外屏片元:铰链边锚定 UV + 模糊/压暗
14.2 资源 app/public/assets/
public/assets/
├── iPhone_Duo_Render.usdc 官方机模(prim 名即 duoScene 白名单 ID 的来源)
├── iPhone_Duo_Star_White.usdz 备用机模(当前代码未引用)
├── textures/ 机身外观贴图(约 50 张,乱码名由 USD 内部引用)
│ ├── KJqLujGRCuLHVMP.png
│ ├── FusPnwQfcVrhWbQ.png
│ └── …
└── ui/ 屏幕内容(ui.js 消费)
├── wallpaper-inner.avif 内屏壁纸底图(外屏由它裁切合成)
├── clock-inner.avif 内屏时钟 overlay
├── clock-outer.avif 外屏时钟 overlay
├── launcher-inner.png 桌面截图(内屏,含边框需裁剪)
└── launcher-outer.png 桌面截图(外屏)
分工:
| 目录 | 谁用 | 何时加载 |
|---|---|---|
textures/ | USDLoader(自动) | 加载机模时按 USD 内引用 |
ui/ | ui.js → loadDefaultUIs() | 场景初始化,合成后转 CanvasTexture |
15. 源码清单
15.1 src/lib/ui.js
/** Load default Wallpaper / Launcher screen canvases from public/assets/ui */
export async function loadDefaultUIs() {
const names = [
'wallpaper-inner.avif',
'clock-inner.avif',
'clock-outer.avif',
'launcher-inner.png',
'launcher-outer.png',
]
const images = Object.fromEntries(
await Promise.all(
names.map(async (name) => {
const image = new Image()
image.src = `/assets/ui/${name}`
await image.decode()
return [name, image]
}),
),
)
const themes = { wallpaper: {}, launcher: {} }
const innerWidth = 1600
for (const [theme, screens] of Object.entries(themes)) {
for (const kind of ['inner', 'outer']) {
const canvas = document.createElement('canvas')
canvas.width = kind === 'inner' ? innerWidth : 774
canvas.height = 1125
const context = canvas.getContext('2d')
if (theme === 'wallpaper') {
context.drawImage(images['wallpaper-inner.avif'], canvas.width - innerWidth, 0, innerWidth, canvas.height)
context.drawImage(images[`clock-${kind}.avif`], 0, 0, canvas.width, canvas.height)
} else {
const crop = kind === 'inner' ? [22, 22, 1072, 754] : [28, 16, 510, 742]
context.drawImage(images[`launcher-${kind}.png`], ...crop, 0, 0, canvas.width, canvas.height)
}
screens[kind] = canvas
}
}
return themes
}
15.2 src/lib/shaders/snippets.js
/**
* Three.js onBeforeCompile 注入用的短片段(普通字符串,非模板字面量)
*/
export const VARYING_UI_POSITION = 'varying vec3 vUIPosition;\n'
export const PROJECT_VERTEX_WITH_UI =
'vUIPosition = transformed;\n' +
'#include <project_vertex>\n'
export const BEGIN_VERTEX_COVER =
'vec2 folded = rotateHinge(position.xz);\n' +
'vec3 transformed = vec3(folded.x, position.y, folded.y);\n'
export const BEGIN_VERTEX_FLEXIBLE =
'vec4 folded = bendStrip(position);\n' +
'vec3 transformed = vec3(folded.x, position.y, folded.y);\n'
export const BEGIN_NORMAL_COVER =
'vec3 objectNormal = vec3(normal);\n' +
'float a = foldAngle;\n' +
'objectNormal.x = cos(a) * normal.x + sin(a) * normal.z;\n' +
'objectNormal.z = -sin(a) * normal.x + cos(a) * normal.z;\n'
export const BEGIN_NORMAL_FLEXIBLE =
'vec3 objectNormal = vec3(normal);\n' +
'vec4 strip = bendStrip(position);\n' +
'float a = atan(-strip.w, strip.z);\n' +
'objectNormal.x = cos(a) * normal.x + sin(a) * normal.z;\n' +
'objectNormal.z = -sin(a) * normal.x + cos(a) * normal.z;\n'
export const MAP_FRAGMENT_SCREEN = 'diffuseColor.rgb *= screenColor();\n'
/** 把 screenColor() 拼进标准 map_pars_fragment */
export function injectScreenShader(fragmentShader, screenGlsl) {
return fragmentShader
.replace(
'#include <map_pars_fragment>',
'#include <map_pars_fragment>\n' + screenGlsl,
)
.replace('#include <map_fragment>', MAP_FRAGMENT_SCREEN)
}
15.3 src/components/DuoViewer.vue(script + html 部分)
模板 / 样式为纯展示层(滑块、播放、主题、dock 布局),完整见仓库文件。
<script setup>
import { onMounted, onBeforeUnmount, ref, computed } from 'vue'
import { createDuoScene } from '../lib/duoScene.js'
const viewport = ref(null)
const angle = ref(180)
const playing = ref(false)
const ready = ref(false)
const error = ref('')
const theme = ref('wallpaper')
const fileInput = ref(null)
let sceneApi = null
const progressStyle = computed(() => ({
'--progress': `${angle.value / 1.8}%`,
}))
onMounted(() => {
sceneApi = createDuoScene(viewport.value, {
onAngle: (v) => {
angle.value = v
},
onReady: (ok) => {
ready.value = ok
if (!ok) error.value = '模型加载失败,请刷新重试'
},
onError: (err) => {
error.value = err.message || String(err)
},
})
})
onBeforeUnmount(() => {
sceneApi?.dispose()
sceneApi = null
})
function onSlider(e) {
const v = Number(e.target.value)
angle.value = v
sceneApi?.setAngle(v)
playing.value = false
}
function togglePlay() {
if (!sceneApi || !ready.value) return
playing.value = sceneApi.togglePlay()
}
function setTheme(name) {
if (name === 'custom') {
fileInput.value?.click()
return
}
theme.value = name
sceneApi?.setTheme(name)
}
async function onFile(e) {
const file = e.target.files?.[0]
if (!file || !sceneApi) return
try {
await sceneApi.applyCustomImage(file)
theme.value = 'custom'
playing.value = false
angle.value = 180
} catch {
error.value = '无法读取该图片,请选择 PNG / JPG / WebP'
} finally {
e.target.value = ''
}
}
</script>
<template>
<div class="viewer">
<div
ref="viewport"
class="viewport"
role="img"
aria-label="iPhone Duo 3D 模型。拖动旋转,滚轮缩放,滑块开合。"
/>
<p v-if="error" class="banner error">{{ error }}</p>
<p v-else-if="!ready" class="banner">正在加载官方机模与屏幕资源…</p>
<div class="dock">
<aside class="screen-panel" aria-label="屏幕 UI">
<h2>屏幕内容</h2>
<div class="themes" role="tablist">
<button
role="tab"
:aria-selected="theme === 'wallpaper'"
:disabled="!ready"
@click="setTheme('wallpaper')"
>
壁纸
</button>
<button
role="tab"
:aria-selected="theme === 'launcher'"
:disabled="!ready"
@click="setTheme('launcher')"
>
桌面
</button>
<button
role="tab"
:aria-selected="theme === 'custom'"
:disabled="!ready"
@click="setTheme('custom')"
>
自定义
</button>
</div>
<input ref="fileInput" type="file" accept="image/*" hidden @change="onFile" />
</aside>
<section class="controls" aria-label="折叠控制">
<button
class="play"
type="button"
:disabled="!ready"
:aria-label="playing ? '暂停' : '播放'"
@click="togglePlay"
>
<svg v-if="playing" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 6v12M16 6v12" />
</svg>
<svg v-else class="play-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="m9 5 10 7-10 7Z" />
</svg>
</button>
<input
type="range"
min="0"
max="180"
step="0.1"
:value="angle"
:disabled="!ready"
:style="progressStyle"
aria-label="折叠开合"
@input="onSlider"
/>
</section>
</div>
</div>
</template>
15.4 src/lib/duoScene.js
GLSL : src/lib/shaders/
/**
* iPhone Duo 折叠场景(Three.js)
*
* 核心行为:
* 1. 后摄半机固定,盖板半机绕铰链翻转(fold-cover / fold-flexible)
* 2. 内外屏内容按「固定正视投影」采样,再用开合角度做铰链侧模糊 / 压暗
* 3. 本文件管场景生命周期与 mesh 组装;GLSL 在 ./shaders/*.glsl
*
* 角度约定:
* UI angle 180° = 完全展开 → foldAngle 0
* UI angle 0° = 完全合上 → foldAngle π
* bend.value = (180 - angle) / 180 * π
*/
import * as THREE from 'three'
import { USDLoader } from 'three/addons/loaders/USDLoader.js'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'
import { loadDefaultUIs } from './ui.js'
import foldCoverGlsl from './shaders/fold-cover.glsl?raw'
import foldFlexibleGlsl from './shaders/fold-flexible.glsl?raw'
import screenInnerGlsl from './shaders/screen-inner.glsl?raw'
import screenOuterGlsl from './shaders/screen-outer.glsl?raw'
import {
BEGIN_NORMAL_COVER,
BEGIN_NORMAL_FLEXIBLE,
BEGIN_VERTEX_COVER,
BEGIN_VERTEX_FLEXIBLE,
injectScreenShader,
PROJECT_VERTEX_WITH_UI,
VARYING_UI_POSITION,
} from './shaders/snippets.js'
/**
* 在 viewport 内创建 Duo 场景,返回命令式 API(不绑定 Vue 响应式)。
*
* @param {HTMLElement} viewport 放置 WebGL canvas 的容器
* @param {{
* onAngle?: (deg: number) => void,
* onReady?: (ok: boolean) => void,
* onError?: (err: Error) => void,
* }} [hooks]
*/
export function createDuoScene(viewport, hooks = {}) {
// ---------------------------------------------------------------------------
// 场景 / 相机 / 渲染器
// ---------------------------------------------------------------------------
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 250)
// 与 uiReferenceEye 同位置:屏幕 shader 用固定眼睛做投影,Orbit 只影响观看视角
camera.position.set(0, 0, 40)
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
renderer.setClearColor(0xf6f6f3, 0)
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 1.18
viewport.appendChild(renderer.domElement)
// ---------------------------------------------------------------------------
// 环境光 / 反射(金属框、玻璃)
// ---------------------------------------------------------------------------
const environment = new RoomEnvironment() //光照房间
const pmrem = new THREE.PMREMGenerator(renderer)
scene.environment = pmrem.fromScene(environment, 0.04).texture
environment.dispose()
pmrem.dispose()
scene.environmentIntensity = 1.35
scene.add(new THREE.HemisphereLight(0xffffff, 0xb5baa8, 1.8))
const key = new THREE.DirectionalLight(0xfffcf5, 2.6)
key.position.set(-15, 25, 30)
scene.add(key)
const rim = new THREE.DirectionalLight(0xe8edf5, 2)
rim.position.set(15, 5, -15)
scene.add(rim)
// 轨道控制:绕铰链附近旋转,禁止平移,避免机模飞出构图
const controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.enablePan = false
controls.minDistance = 21
controls.maxDistance = 65
controls.target.set(0, 0, 0.275454) // 铰链轴附近
controls.update()
/** 所有拆平后的机模 mesh 挂在此 Group 下 */
const phone = new THREE.Group()
scene.add(phone)
// ---------------------------------------------------------------------------
// 折叠 / 播放状态
// ---------------------------------------------------------------------------
/** GPU uniform:foldAngle(弧度)。改 .value 即驱动所有 fold 着色器 */
const bend = { value: 0 }
/** UI 开合角(度),180 展开 / 0 合上 */
let angle = 180
let playing = false
/** 自动播放时间轴相位,周期 8.6s */
let phase = 0
/** 平滑过渡:{ from, to, elapsed },自定义图时收到 180° */
let transition = null
let ready = false
let disposed = false
/**
* 内外屏运行时状态:
* material / defaultTextures / frame / gradient / pixel(均为 shader uniforms 包装)
*/
const screens = {}
/**
* 屏幕投影用的「固定眼睛」,刻意与 Orbit 解耦:
* 转视角时 UI 仍保持广告正面投影,不会贴在弯曲表面上扭歪。
*/
const uiReferenceEye = new THREE.Vector3(0, 0, 40)
/**
* 展开平面上的 UI 矩形:xy = 左下角,zw = 宽高(模型坐标,已含 Y 平移修正)。
* 与下方重建 UV、screen-*.glsl 中的常数同源,改模型后需一起校准。
*/
const innerUIFrame = new THREE.Vector4(-7.89935, 0.34562 - 5.8974, 15.7987, 11.1035)
// 外屏物理深度不同,按透视比例缩放 frame,使投影尺度与内屏对齐
const outerUIFrame = new THREE.Vector4(0.23396, 0.27173 - 5.8974, 7.73936, 11.2513).multiplyScalar(
(uiReferenceEye.z - 0.24948) / (uiReferenceEye.z - 0.825538),
)
// ---------------------------------------------------------------------------
// 屏幕贴图(默认主题 + 自定义上传共用 canvas)
// ---------------------------------------------------------------------------
let uiTheme = 'wallpaper'
const uiCanvas = document.createElement('canvas')
uiCanvas.width = 1600
uiCanvas.height = 1125
const uiTexture = new THREE.CanvasTexture(uiCanvas)
uiTexture.colorSpace = THREE.SRGBColorSpace
uiTexture.anisotropy = renderer.capabilities.getMaxAnisotropy()
/**
* 写入开合角:同步 bend(GPU)与外屏显隐。
* angle≥180 时外屏乘黑,避免展开态外屏仍发亮。
*/
function setAngle(value) {
angle = value
bend.value = ((180 - value) / 180) * Math.PI
if (screens.outer?.material) {
screens.outer.material.color.setScalar(value >= 180 ? 0 : 1)
}
hooks.onAngle?.(value)
}
function setPlaying(value) {
playing = value
}
/** 把 wallpaper / launcher 主题纹理绑到内外屏,并刷新 shader 用的 frame / gradient / pixel */
function showDefaultUI() {
for (const [kind, screen] of Object.entries(screens)) {
const texture = screen.defaultTextures[uiTheme]
screen.material.map = texture
screen.pixel.value.set(1 / texture.image.width, 1 / texture.image.height)
screen.frame.value.copy(kind === 'inner' ? innerUIFrame : outerUIFrame)
// inner: 模糊从中缝(0.5)往一侧扫;outer: 从铰链边(0)往另一侧
screen.gradient.value.set(kind === 'inner' ? 0.5 : 0, kind === 'inner' ? 0 : 1)
}
}
function setTheme(theme) {
if (theme === 'custom') return
uiTheme = theme
showDefaultUI()
}
/** 用户上传图片:居中铺到 uiCanvas,内外屏共用,并过渡到展开角 */
async function applyCustomImage(file) {
const url = URL.createObjectURL(file)
const img = new Image()
img.src = url
try {
await img.decode()
const c = uiCanvas.getContext('2d')
c.fillStyle = '#101418'
c.fillRect(0, 0, uiCanvas.width, uiCanvas.height)
const scale = Math.min(uiCanvas.width / img.width, uiCanvas.height / img.height)
const width = img.width * scale
const height = img.height * scale
c.drawImage(img, (uiCanvas.width - width) / 2, (uiCanvas.height - height) / 2, width, height)
uiTexture.needsUpdate = true
for (const [kind, screen] of Object.entries(screens)) {
screen.material.map = uiTexture
screen.pixel.value.set(1 / uiCanvas.width, 1 / uiCanvas.height)
screen.frame.value.copy(innerUIFrame)
screen.gradient.value.set(0.5, kind === 'inner' ? 0 : 1)
}
uiTheme = 'custom'
setPlaying(false)
transition = { from: angle, to: 180, elapsed: 0 }
} finally {
URL.revokeObjectURL(url)
}
}
/**
* 按视口反推 FOV,使机模在不同窗口高度下视觉尺度接近。
* pixelsPerUnit 限制了「每世界单位对应多少像素」。
*/
function resize() {
if (disposed) return
const { width, height } = viewport.getBoundingClientRect()
if (width < 1 || height < 1) return
renderer.setSize(width, height)
camera.aspect = width / height
const pixelsPerUnit = Math.min(width / 25, height / 17, 37)
camera.fov = THREE.MathUtils.radToDeg(2 * Math.atan(height / pixelsPerUnit / 2 / 40))
camera.updateProjectionMatrix()
}
const ro = new ResizeObserver(resize)
ro.observe(viewport)
// ---------------------------------------------------------------------------
// 渲染循环:播放曲线 / 过渡插值 / Orbit / draw
// ---------------------------------------------------------------------------
let lastTime = performance.now()
/**
* 每帧回调(由 WebGLRenderer 驱动,约等于 requestAnimationFrame)。
* 优先级:playing 自动开合 > transition 平滑过渡 > 仅渲染(滑块已在外部 setAngle)。
*/
renderer.setAnimationLoop((now) => {
if (disposed) return
// delta:本帧间隔(秒)。上限 0.05 避免切后台回来一帧跳太大导致动画瞬移
const delta = Math.min((now - lastTime) / 1000, 0.05)
lastTime = now
if (ready && playing) {
/**
* 自动演示时间轴(总长 8.6s,phase 对 8.6 取模循环)
*
* phase angle 曲线
* ---------- ------------------------------------------
* [0, 1.2) 钉在 180°(展开停留)
* [1.2, 4.3) 余弦从 180→0(合上,时长 3.1s)
* 公式:90*(1+cos(t*π)),t∈[0,1] → 180→0
* [4.3, 5.5) 钉在 0°(合上停留)
* [5.5, 8.6) 余弦从 0→180(打开,时长 3.1s)
* 公式:90*(1-cos(t*π)),t∈[0,1] → 0→180
*
* 起播时 togglePlay 会按当前 angle 反算 phase,避免半开处跳变。
*/
phase = (phase + delta) % 8.6
let value
if (phase < 1.2) {
value = 180
} else if (phase < 4.3) {
// t = (phase-1.2)/3.1 ∈ [0,1]:展开 → 合上
value = 90 * (1 + Math.cos(((phase - 1.2) / 3.1) * Math.PI))
} else if (phase < 5.5) {
value = 0
} else {
// t = (phase-5.5)/3.1 ∈ [0,1]:合上 → 展开
value = 90 * (1 - Math.cos(((phase - 5.5) / 3.1) * Math.PI))
}
setAngle(value) // 内部写 bend.value,驱动 fold / screen shader
} else if (transition) {
/**
* 单次平滑过渡(如自定义图后收到 180°)。
* progress ∈ [0,1] 线性时间;ease = smoothstep(progress) 两端减速。
* 结束后清空 transition,下一帧不再进此分支。
*/
transition.elapsed += delta
const progress = Math.min(transition.elapsed / 1.4, 1)
const ease = progress * progress * (3 - 2 * progress) // Hermite smoothstep
setAngle(THREE.MathUtils.lerp(transition.from, transition.to, ease))
if (progress === 1) transition = null
}
// else:既不播放也不过渡 → angle 保持上次值(用户拖滑块已直接 setAngle)
controls.update() // 阻尼惯性,需每帧调
renderer.render(scene, camera)
})
// ---------------------------------------------------------------------------
// 异步初始化:UI 纹理 → USD 机模 → 分类 mesh → 注入着色器
// ---------------------------------------------------------------------------
;(async () => {
try {
// --- 1) 预生成内外屏 CanvasTexture(wallpaper / launcher)---
const defaultUIs = await loadDefaultUIs()
for (const kind of ['inner', 'outer']) {
const defaultTextures = {}
for (const [theme, canvases] of Object.entries(defaultUIs)) {
const texture = new THREE.CanvasTexture(canvases[kind])
texture.colorSpace = THREE.SRGBColorSpace
texture.anisotropy = renderer.capabilities.getMaxAnisotropy()
defaultTextures[theme] = texture
}
// toneMapped: false —— 屏幕内容不做 ACES,保持 UI 原色
const material = new THREE.MeshBasicMaterial({ map: defaultTextures[uiTheme], toneMapped: false })
screens[kind] = {
material,
defaultTextures,
frame: { value: (kind === 'inner' ? innerUIFrame : outerUIFrame).clone() },
gradient: { value: new THREE.Vector2(kind === 'inner' ? 0.5 : 0, kind === 'inner' ? 0 : 1) },
pixel: {
value: new THREE.Vector2(
1 / defaultUIs[uiTheme][kind].width,
1 / defaultUIs[uiTheme][kind].height,
),
},
}
}
console.log('内外屏',screens)
// --- 2) 加载官方 USDC,烘焙世界矩阵,Y 平移对齐铰链 ---
const model = await new USDLoader().loadAsync('/assets/iPhone_Duo_Render.usdc')
model.scale.multiplyScalar(100)
model.updateMatrixWorld(true)
// --- 3) 遍历每个 mesh:分类 + 可选重建 UV + 注入 fold/screen shader ---
model.traverse((object) => {
if (!object.isMesh) return
console.log('object',object)
// 拆成独立几何(不再依赖 USD 层级矩阵),整体下移对齐铰链
const geometry = object.geometry.clone().applyMatrix4(object.matrixWorld)
geometry.translate(0, -5.8974, 0)
// 沿父节点向上找半机归属:
// upTUAKvMVkPOMKq = 盖板侧(moving)
// SiftyleUEEZwLhF = 后摄侧(fixed,不注入 fold)
let ancestor = object
while (ancestor && !['upTUAKvMVkPOMKq', 'SiftyleUEEZwLhF'].includes(ancestor.name)) {
ancestor = ancestor.parent
}
const moving = ancestor?.name === 'upTUAKvMVkPOMKq'
// 铰链附近柔性 mesh(含内屏):走 bendStrip,否则盖板走 rotateHinge
const flexible = ['JnJdTkxbQgUtLwU', 'xdyyaajWsatVNxN', 'UXtsBZYlaUvHoEh', 'MvKPXGSdYDVvSpk'].includes(
object.name,
)
// 屏幕 mesh 名 → 绑定 screens.inner / screens.outer
const kind =
object.name === 'UXtsBZYlaUvHoEh' ? 'inner' : object.name === 'hhgAIoCGsHXeDPY' ? 'outer' : null
const material = kind ? screens[kind].material : object.material.clone()
// 屏幕:用世界坐标归一化重建 UV,供基础 map;真正取色仍以 screen shader 投影为主
if (kind) {
const p = geometry.attributes.position
const uv = new Float32Array(p.count * 2)
for (let i = 0; i < p.count; i++) {
uv[i * 2] =
kind === 'inner' ? (p.getX(i) + 7.89935) / 15.7987 : (-0.23396 - p.getX(i)) / 7.73936
uv[i * 2 + 1] =
kind === 'inner'
? (p.getY(i) + 5.8974 - 0.34562) / 11.1035
: (p.getY(i) + 5.8974 - 0.27173) / 11.2513
}
geometry.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
}
// 盖板 / 柔性:编译前改写标准材质的 vertex(折)+ fragment(屏)
if (moving || flexible) {
debugger
material.onBeforeCompile = (shader) => {
// 共享同一个 bend 对象引用,滑块改 angle 即全局生效
shader.uniforms.foldAngle = bend
if (kind) {
// 注入 screen-inner / screen-outer:投影 UV + 模糊
shader.uniforms.uiFrame = screens[kind].frame
shader.uniforms.uiGradient = screens[kind].gradient
shader.uniforms.uiReferenceEye = { value: uiReferenceEye }
shader.uniforms.uiPixel = screens[kind].pixel
const screenGlsl = kind === 'inner' ? screenInnerGlsl : screenOuterGlsl
shader.fragmentShader = injectScreenShader(shader.fragmentShader, screenGlsl)
// 变形后的位置传给片元做投影
shader.vertexShader = VARYING_UI_POSITION + shader.vertexShader
shader.vertexShader = shader.vertexShader.replace(
'#include <project_vertex>',
PROJECT_VERTEX_WITH_UI,
)
}
// 顶点折叠:柔性用 bendStrip,盖板刚体用 rotateHinge
const foldGlsl = flexible ? foldFlexibleGlsl : foldCoverGlsl
shader.vertexShader = foldGlsl + '\n' + shader.vertexShader
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
flexible ? BEGIN_VERTEX_FLEXIBLE : BEGIN_VERTEX_COVER,
)
// 法线同步旋转,保证折后光照正确
shader.vertexShader = shader.vertexShader.replace(
'#include <beginnormal_vertex>',
flexible ? BEGIN_NORMAL_FLEXIBLE : BEGIN_NORMAL_COVER,
)
}
// 不同变体必须不同 cache key,否则 Three 会串用编译好的 program
material.customProgramCacheKey = () =>
(flexible ? 'fold-flexible' : 'fold-cover') + '-' + (kind || 'body')
}
const mesh = new THREE.Mesh(geometry, material)
mesh.name = object.name
// 大角度折叠时包围盒不准,关闭视锥裁剪以免半机消失
mesh.frustumCulled = false
phone.add(mesh)
})
showDefaultUI()
ready = true
setAngle(180)
resize()
hooks.onReady?.(true)
} catch (error) {
console.error(error)
hooks.onError?.(error instanceof Error ? error : new Error(String(error)))
hooks.onReady?.(false)
}
})()
// ---------------------------------------------------------------------------
// 对外 API(供 DuoViewer.vue 调用)
// ---------------------------------------------------------------------------
return {
getAngle: () => angle,
getPlaying: () => playing,
isReady: () => ready,
/** 手动拖滑块:停播放、清过渡 */
setAngle: (v) => {
transition = null
setPlaying(false)
setAngle(v)
},
/**
* 开关自动演示。起播时按当前 angle 反算 phase,
* 避免从半开位置直接跳到时间轴起点。
*/
togglePlay: () => {
transition = null
if (!playing) phase = 1.2 + (Math.acos((2 * angle) / 180 - 1) / Math.PI) * 3.1
setPlaying(!playing)
return playing
},
setPlaying,
setTheme,
applyCustomImage,
/** 卸载:停循环、释放 WebGL / controls */
dispose: () => {
disposed = true
ro.disconnect()
renderer.setAnimationLoop(null)
controls.dispose()
renderer.dispose()
if (renderer.domElement.parentNode === viewport) {
viewport.removeChild(renderer.domElement)
}
},
}
}
16. 相关文档
- Vue 3 组合式 API:cn.vuejs.org/guide/extra…
- Three.js 手册(场景 / 相机 / 材质):threejs.org/docs/index.…
- WebGLRenderer.setAnimationLoop:threejs.org/docs/#api/e…
- onBeforeCompile 说明(论坛):discourse.threejs.org/t/beforecom…
- USDLoader 源码:
node_modules/three/examples/jsm/loaders/USDLoader.js(解析赋名在usd/USDComposer.js) - USD 格式总览(Pixar):openusd.org/release/int…
- GLSL ES 3.0 规范(textureLod / dFdx 等):registry.khronos.org/OpenGL/spec…
- Hermite / smoothstep 插值:en.wikipedia.org/wiki/Smooth…