📱iPhone Duo 开屏动画咋实现的?

0 阅读25分钟

GIFIPHONE.gif 前段时间苹果发布会上看到iPhone Duo的开屏动画很是惊艳。iPhone Duo 的开屏动画,其核心是一种渐进式模糊与虚化过渡效果。当你展开或折叠手机时,外屏的画面会自然地逐渐模糊,仿佛消融并“融化”进内屏,而不是生硬地切换。这个过程就像一个光学错觉,让你感觉像是透过一扇透明的窗户在看屏幕,两个屏幕在视觉上被无缝连接成一个整体,从而彻底消除了其他折叠屏手机常见的画面跳跃感和割裂感。

苹果官方体验


1. 总览:角度怎样变成画面

滑块 / 播放 / 过渡
    → setAngle(angle°)
    → bend.value = foldAngle(弧度)
    → 每帧 renderer.render
         ├─ 顶点 fold-*.glsl:机身变形
         └─ 片元 screen-*.glsl:投影 UV → 壁纸采样 → 模糊 / 压暗

setAngle 不画图,只改状态;真正出画面是动画循环末尾的 render。

image.png


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 = valueJS 侧记录界面角度(度):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. 每帧循环:谁在改角度、谁在画

image.png

  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)
  })

优先级:

  1. 播放中:8.6s 一圈——展开停留 → 余弦合上 → 合上停留 → 余弦打开,每帧 setAngle
  2. 有过渡(如自定义图):1.4s smoothstep 收到目标角
  3. 否则:角度保持上次(用户拖滑块时已在外部调过 setAngle)
  4. 最后 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 = 0bendStrip 按 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 看屏幕

01-总览与初始化-坐标系与铰链轴.drawio.png


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)
作用
uiCanvas2D 画布,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。

image.png

7.1 traverse 里的 object 是什么?

      model.traverse((object) => {
        if (!object.isMesh) return

object 是场景树里扫到的每个对象;过滤后只处理 Mesh——机模里的一块零件(有几何、材质、object.name)。
一整个手机通常有二三十个 mesh(镜头、按键、中框、内外屏、铰链件等),所以 console.log(object) 会打出很多名字,这是正常的。

object.name 是 USD 导出时的节点 ID(乱码式字符串),换模型后会变。

这些名字的来源(具体文件):

  1. 源头:app/public/assets/iPhone_Duo_Render.usdc
    USD Crate 二进制里每个 prim(节点)自带名字。直接在文件字节里能扫到 upTUAKvMVkPOMKq、SiftyleUEEZwLhF、JnJdTkxbQgUtLwU、UXtsBZYlaUvHoEh 等,与 SkelRoot / Mesh / MaterialBindingAPI 等 USD 保留字相邻——它们就是机模的 prim 命名。

  2. 解析赋值:node_modules/three/examples/jsm/loaders/usd/USDComposer.js
    加载器逐 prim 构建 Object3D 时把名字搬上去(obj.name = name)。名字不是 JS 定义的,是文件里带的。

  3. 消费: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 弯;两侧分别接固定半机 / 盖板。

折叠只改形状;屏幕上画什么由片元决定。

image.png

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 为例)

壁纸能显示、折叠变糊变黑,主要靠这套逻辑。

image.png

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));

折叠时边上的黑色三角 / 楔形,不是另画的三角面片,而是叠出来的:

  1. 上式铰链侧压暗
  2. UV 出界 coverage≈0 当黑,模糊后渗到角上
  3. 折弯后投影对不齐,贴图盖不满
  4. 机身铰链厚度等实心几何

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
progressfoldAngle / (π/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 步本质是:在外屏靠铰链的边线上钉一颗图钉,算出手机折叠后这颗图钉甩到了哪里。

image.png

  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。下面只关心折后图钉落点:

foldAnglefoldedEdge (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 三处咬合(同一物理边线,必须一致):

  1. UV 重建(§7.6):uv.x = (-0.23396 - x) / 7.73936 → UV.x = 0 对应 x = -0.23396
  2. 外屏 UI 矩形(§4):outerUIFrame.x = 0.23396
  3. 图钉: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. 建议阅读顺序

  1. setAngle + 动画循环末尾 render
  2. fold-cover.glsl / fold-flexible.glsl(流程图:「fold 折叠变形流程」页)
  3. traverse 里 moving / flexible / kind
  4. screen-inner.glsl(再对比 screen-outer.glsl;流程图:「screenColor 流程」页)
  5. loadDefaultUIs → showDefaultUI → applyCustomImage

对照:diagrams/duoScene-flow.drawio,共 7 页:

  1. 总览与初始化
  2. 网格分类与折叠
  3. 屏幕投影与模糊
  4. screenColor 流程(对应本文 §10)
  5. fold 折叠变形流程(对应本文 §9)
  6. 坐标系与铰链轴(对应本文 §4.1 / §10.6 / §11)
  7. 角度驱动与渲染

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. 相关文档