- 总览:角度怎样变成画面
滑块 / 播放 / 过渡
→ setAngle(angle°)
→ <span>bend.value</span> = foldAngle(弧度)
→ 每帧 renderer.render
├─ 顶点 fold-*.glsl:机身变形
└─ 片元 screen-*.glsl:投影 UV → 壁纸采样 → 模糊 / 压暗
setAngle 不画图,只改状态;真正出画面是动画循环末尾的 render。
- 角度写入:
setAngle
<span>function</span> <span>setAngle</span>(<span>value</span>) {
angle = value
bend.<span>value</span> = ((<span>180</span> - value) / <span>180</span>) * <span>Math</span>.<span>PI</span>
<span>if</span> (screens.<span>outer</span>?.<span>material</span>) {
screens.<span>outer</span>.<span>material</span>.<span>color</span>.<span>setScalar</span>(value >= <span>180</span> ? <span>0</span> : <span>1</span>)
}
hooks.<span>onAngle</span>?.(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 越小(方向相反)。
- 每帧循环:谁在改角度、谁在画
renderer.<span>setAnimationLoop</span>(<span>(<span>now</span>) =></span> {
<span>// …</span>
<span>if</span> (ready && playing) {
phase = (phase + delta) % <span>8.6</span>
<span>// 分段算出 value → setAngle(value)</span>
} <span>else</span> <span>if</span> (transition) {
<span>// smoothstep 插值 → setAngle(...)</span>
}
controls.<span>update</span>()
renderer.<span>render</span>(scene, camera)
})
优先级:
- 播放中:8.6s 一圈——展开停留 → 余弦合上 → 合上停留 → 余弦打开,每帧
setAngle - 有过渡(如自定义图):1.4s smoothstep 收到目标角
- 否则:角度保持上次(用户拖滑块时已在外部调过
setAngle) - 最后
controls.update()+renderer.render(...)—— 着色器读当前bend.value画出这一帧
delta 用 Math.min(..., 0.05) 钳制,避免切后台回来一帧跳太大。
- 固定眼睛与 UI 矩形(展开态标定)
<span>const</span> uiReferenceEye = <span>new</span> <span>THREE</span>.<span>Vector3</span>(<span>0</span>, <span>0</span>, <span>40</span>)
<span>const</span> innerUIFrame = <span>new</span> <span>THREE</span>.<span>Vector4</span>(-<span>7.89935</span>, <span>0.34562</span> - <span>5.8974</span>, <span>15.7987</span>, <span>11.1035</span>)
<span>const</span> outerUIFrame = <span>new</span> <span>THREE</span>.<span>Vector4</span>(<span>0.23396</span>, <span>0.27173</span> - <span>5.8974</span>, <span>7.73936</span>, <span>11.2513</span>).<span>multiplyScalar</span>(
(uiReferenceEye.<span>z</span> - <span>0.24948</span>) / (uiReferenceEye.<span>z</span> - <span>0.825538</span>),
)
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 看屏幕 |
- 屏幕状态:
screens与主题
初始化时为内外屏各建一份状态:
screens[kind] = {
material,
defaultTextures,
<span>frame</span>: { <span>value</span>: (kind === <span>'inner'</span> ? innerUIFrame : outerUIFrame).<span>clone</span>() },
<span>gradient</span>: { <span>value</span>: <span>new</span> <span>THREE</span>.<span>Vector2</span>(kind === <span>'inner'</span> ? <span>0.5</span> : <span>0</span>, kind === <span>'inner'</span> ? <span>0</span> : <span>1</span>) },
<span>pixel</span>: {
<span>value</span>: <span>new</span> <span>THREE</span>.<span>Vector2</span>(
<span>1</span> / defaultUIs[uiTheme][kind].<span>width</span>,
<span>1</span> / defaultUIs[uiTheme][kind].<span>height</span>,
),
},
}
| 字段 | 作用 |
|---|---|
| `material` | 屏幕材质,`map` 即当前 UI 贴图 |
| `defaultTextures` | `{ wallpaper, launcher }`,切换预置主题时直接换 |
| `frame` | 投影矩形,挂到 `shader.uniforms.uiFrame`(引用) |
| `gradient` | 模糊扫过方向:内屏 `(0.5, 0)`,外屏 `(0, 1)` |
| `pixel` | `(1/宽, 1/高)`,模糊核采样步长 |
挂到 shader 时同样是引用:
shader.<span>uniforms</span>.<span>foldAngle</span> = bend
<span>// …</span>
shader.<span>uniforms</span>.<span>uiFrame</span> = screens[kind].<span>frame</span>
shader.<span>uniforms</span>.<span>uiGradient</span> = screens[kind].<span>gradient</span>
shader.<span>uniforms</span>.<span>uiReferenceEye</span> = { <span>value</span>: uiReferenceEye }
shader.<span>uniforms</span>.<span>uiPixel</span> = screens[kind].<span>pixel</span>
之后 showDefaultUI 改 frame.value / gradient.value,下一帧自动生效,无需重编译。
预置主题如何挂上
<span>function</span> <span>showDefaultUI</span>(<span></span>) {
<span>for</span> (<span>const</span> [kind, screen] <span>of</span> <span>Object</span>.<span>entries</span>(screens)) {
<span>const</span> texture = screen.<span>defaultTextures</span>[uiTheme]
screen.<span>material</span>.<span>map</span> = texture
screen.<span>pixel</span>.<span>value</span>.<span>set</span>(<span>1</span> / texture.<span>image</span>.<span>width</span>, <span>1</span> / texture.<span>image</span>.<span>height</span>)
screen.<span>frame</span>.<span>value</span>.<span>copy</span>(kind === <span>'inner'</span> ? innerUIFrame : outerUIFrame)
screen.<span>gradient</span>.<span>value</span>.<span>set</span>(kind === <span>'inner'</span> ? <span>0.5</span> : <span>0</span>, kind === <span>'inner'</span> ? <span>0</span> : <span>1</span>)
}
}
<span>function</span> <span>setTheme</span>(<span>theme</span>) {
<span>if</span> (theme === <span>'custom'</span>) <span>return</span>
uiTheme = theme
<span>showDefaultUI</span>()
}
loadDefaultUIs()(见下一节)负责造图;showDefaultUI()负责挂到材质并刷新 uniform。theme === 'custom'直接 return:自定义图不在defaultTextures里,必须走applyCustomImage再选文件。
自定义图:uiCanvas + uiTexture
<span>let</span> uiTheme = <span>'wallpaper'</span>
<span>const</span> uiCanvas = <span>document</span>.<span>createElement</span>(<span>'canvas'</span>)
uiCanvas.<span>width</span> = <span>1600</span>
uiCanvas.<span>height</span> = <span>1125</span>
<span>const</span> uiTexture = <span>new</span> <span>THREE</span>.<span>CanvasTexture</span>(uiCanvas)
| 作用 | |
|---|---|
| `uiCanvas` | 2D 画布,`drawImage` 用户图 |
| `uiTexture` | 把该 canvas 当 WebGL 贴图,`material.map = uiTexture` |
默认壁纸 / 桌面不用这一对,只用 defaultTextures。上传成功后 uiTheme = 'custom',并 transition 收到 180°。
- 默认 UI 合成:
loadDefaultUIs
<span>export</span> <span>async</span> <span>function</span> <span>loadDefaultUIs</span>(<span></span>) {
<span>// 加载 wallpaper / clock / launcher 图片</span>
<span>// 离屏 canvas 合成 themes.wallpaper|launcher × inner|outer</span>
<span>return</span> themes
}
从 public/assets/ui/ 读图(壁纸、时钟、桌面),画成 4 张 canvas 返回。
注意:这里的 ui/ 是屏幕内容;机身外壳贴图在 public/assets/textures/,由 USD 加载时自动绑定,两者不是一回事。
- 机模加载:
USDLoader与网格分类
<span>const</span> model = <span>await</span> <span>new</span> <span>USDLoader</span>().<span>loadAsync</span>(<span>'/assets/iPhone_Duo_Render.usdc'</span>)
model.<span>scale</span>.<span>multiplyScalar</span>(<span>100</span>)
model.<span>updateMatrixWorld</span>(<span>true</span>)
USDLoader:把 USDC 机模读进 Three.js(网格、材质、节点名),并按引用加载 textures/。类似 glTF 的 GLTFLoader,只是格式是 USD。
7.1 traverse 里的 object 是什么?
model.<span>traverse</span>(<span>(<span>object</span>) =></span> {
<span>if</span> (!object.<span>isMesh</span>) <span>return</span>
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
<span>// 祖先 upTUAKvMVkPOMKq → 盖板侧(moving)</span>
<span>// 祖先 SiftyleUEEZwLhF → 后摄侧(fixed,不折)</span>
<span>let</span> ancestor = object
<span>while</span> (ancestor && ![<span>'upTUAKvMVkPOMKq'</span>, <span>'SiftyleUEEZwLhF'</span>].<span>includes</span>(ancestor.<span>name</span>)) {
ancestor = ancestor.<span>parent</span>
}
<span>const</span> moving = ancestor?.<span>name</span> === <span>'upTUAKvMVkPOMKq'</span>
这两个不是「左右两条金属边框」,而是 USD 里两半机的父节点(分组根):
| 名字 | 含义 |
|---|---|
| `upTUAKvMVkPOMKq` | **盖板半机**整棵子树(外屏那一边) |
| `SiftyleUEEZwLhF` | **后摄半机**整棵子树(固定、带后摄那一边) |
往上找祖先,是为了判断当前零件属于「会翻的一半」还是「不动的一半」。
是否包含屏幕? 会。屏幕是挂在半机下面的子 mesh,不是这两个字符串本身:
- 盖板半机下常见:外屏
hhgAIoCGsHXeDPY、盖板金属壳等 - 后摄半机下常见:壳、镜头等
- 内屏
UXtsBZYlaUvHoEh:以柔性名单为准走bendStrip;层级上通常也挂在某一半下面
7.3 柔性名单:为什么只有 4 个,不是全部 mesh?
<span>const</span> flexible = [<span>'JnJdTkxbQgUtLwU'</span>, <span>'xdyyaajWsatVNxN'</span>, <span>'UXtsBZYlaUvHoEh'</span>, <span>'MvKPXGSdYDVvSpk'</span>].<span>includes</span>(
object.<span>name</span>,
)
这是白名单:只有要用中缝弯曲(bendStrip)的零件才写进来,不是「所有 object.name 的完整列表」。
| 名字 | 角色 |
|---|---|
| `UXtsBZYlaUvHoEh` | 内屏(同时 `kind === 'inner'`) |
| `JnJdTkxbQgUtLwU`、`xdyyaajWsatVNxN`、`MvKPXGSdYDVvSpk` | 中缝附近其它会弯的结构件 |
你打印到的其它名字(例如 NtYMsWnAcbWQvBP)代码里没写,说明它不是柔性件:要么在后摄半机上固定不动,要么在盖板半机上走刚体翻转。可用下面方式确认归属:
<span>console</span>.<span>log</span>(object.<span>name</span>, { moving, flexible, <span>ancestor</span>: ancestor?.<span>name</span> })
7.4 moving / flexible / 屏幕 kind
<span>const</span> kind =
object.<span>name</span> === <span>'UXtsBZYlaUvHoEh'</span> ? <span>'inner'</span> : object.<span>name</span> === <span>'hhgAIoCGsHXeDPY'</span> ? <span>'outer'</span> : <span>null</span>
| 类型 | 判定 | 行为 |
|---|---|---|
| 后摄侧 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):谁要注入折叠着色器?
<span>if</span> (moving || flexible) {
只有「盖板半机上的件」或「柔性白名单里的件」才进 onBeforeCompile 挂 fold。
后摄半机固定件两边都不进,几何原样。
内部再选 GLSL:
<span>const</span> foldGlsl = flexible ? foldFlexibleGlsl : foldCoverGlsl
柔性 → 中缝弯;否则盖板刚体 → 壳体硬翻。
7.6 屏幕重建 UV(不是模糊)
<span>if</span> (kind) {
<span>// 按位置算 uv[i*2], uv[i*2+1]</span>
geometry.<span>setAttribute</span>(<span>'uv'</span>, <span>new</span> <span>THREE</span>.<span>BufferAttribute</span>(uv, <span>2</span>))
}
只给屏幕 mesh 建基础 UV。壁纸怎么糊、黑角怎么来,全在 screen-*.glsl。
onBeforeCompile:何时注入、和每帧渲染的关系
<span>if</span> (moving || flexible) {
material.<span>onBeforeCompile</span> = <span>(<span>shader</span>) =></span> {
shader.<span>uniforms</span>.<span>foldAngle</span> = bend
<span>// 屏幕则再挂 uiFrame / 注入 screen-*.glsl</span>
<span>// 顶点插入 fold-*.glsl,替换 begin_vertex / beginnormal_vertex</span>
}
material.<span>customProgramCacheKey</span> = <span>() =></span> <span>/* fold-flexible|cover + kind */</span>
}
- 时机:材质第一次被
render画到时(或 cache key 变了重编译),在renderer.render内部触发。 - 不是每帧在
controls.update前后跑一遍。 - 注入后:顶点负责折,片元负责屏;之后只靠改
bend.value等驱动动画。
customProgramCacheKey 区分柔性 / 盖板 / 是否屏幕,避免 Three.js 串用同一套已编译程序。
- 顶点折叠:
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 位点:
<span>// 顶点位置:盖板刚体</span>
<span>export</span> <span>const</span> <span>BEGIN_VERTEX_COVER</span> =
<span>'vec2 folded = rotateHinge(position.xz);\n'</span> +
<span>'vec3 transformed = vec3(folded.x, position.y, folded.y);\n'</span>
<span>// 顶点位置:柔性</span>
<span>export</span> <span>const</span> <span>BEGIN_VERTEX_FLEXIBLE</span> =
<span>'vec4 folded = bendStrip(position);\n'</span> +
<span>'vec3 transformed = vec3(folded.x, position.y, folded.y);\n'</span>
<span>// 法线:盖板用同一角度直接旋转</span>
<span>export</span> <span>const</span> <span>BEGIN_NORMAL_COVER</span> =
<span>'vec3 objectNormal = vec3(normal);\n'</span> +
<span>'float a = foldAngle;\n'</span> +
<span>'objectNormal.x = cos(a) * normal.x + sin(a) * normal.z;\n'</span> +
<span>'objectNormal.z = -sin(a) * normal.x + cos(a) * normal.z;\n'</span>
<span>// 法线:柔性由 bendStrip 返回的切线反求角度</span>
<span>export</span> <span>const</span> <span>BEGIN_NORMAL_FLEXIBLE</span> =
<span>'vec3 objectNormal = vec3(normal);\n'</span> +
<span>'vec4 strip = bendStrip(position);\n'</span> +
<span>'float a = atan(-strip.w, strip.z);\n'</span> + <span>// 切线 → 局部折叠角</span>
<span>'objectNormal.x = cos(a) * normal.x + sin(a) * normal.z;\n'</span> +
<span>'objectNormal.z = -sin(a) * normal.x + cos(a) * normal.z;\n'</span>
汇合点:两条路最后都得到 transformed = vec3(折后 x, position.y, 折后 z),再经 project_vertex 投影并写出 vUIPosition,交给 §10 的 screenColor() 做屏幕取色——折叠变形与屏幕投影正是通过这个 varying 串联的。
补充说明:
- 两个文件各带一份相同的
rotateHinge,按文件各自拼接进 shader,互不影响。 - 弯曲只发生在 x 方向,高度 y 不变。
screen-outer.glsl里也有一个rotateHinge,但它不变形顶点,只折「铰链边一个点」来算anchorX(见 §10.5)。
- 片元:投影、模糊、黑三角(以
screen-inner.glsl为例)
壁纸能显示、折叠变糊变黑,主要靠这套逻辑。
10.0 screenColor() 总流程(内外屏共用骨架)
screenColor() 每个屏幕片元执行一次(<span>vUIPosition</span> = 折叠变形后的位置)
│
├─ 1) 固定正视投影
│ 眼睛 (0,0,40) → 片元打射线
│ 与展开平面 <span>z</span>=<span>0.24948</span> 求交 → projected
│ <span>sourceUV</span> = (projected - frame.xy) / frame.zw
│
├─ 2) 屏幕差异步骤(内屏 / 外屏不同,见 §10.5 / §10.6)
│ 内屏:无额外步骤,<span>progress</span> = foldAngle / (π/<span>2</span>),越折越糊
│ 外屏:先折铰链边点投影得 anchorX 卷 UV.x;
│ <span>progress</span> = (π - foldAngle) / (π/<span>2</span>),越开越糊
│
├─ 3) 横向梯度 → 两个强度
│ edge:UV.x 相对 gradient 区间的进度
│ <span>radius</span> = <span>72</span> × motion × blur^<span>1.35</span> ← 模糊半径
│ <span>effect</span> = motion × darken^<span>1.35</span> ← 压暗强度
│
├─ 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 压暗与「黑色三角」从哪来
<span>return</span> color * (<span>1.0</span> - <span>min</span>(<span>1.0</span>, effect * <span>2.0</span>));
折叠时边上的黑色三角 / 楔形,不是另画的三角面片,而是叠出来的:
- 上式铰链侧压暗
- 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,不需要这步。
- 关键常数(与机模对齐)
| 常数 | 用途 |
|---|---|
| `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(外屏图钉)。
- 对外 API
| 方法 | 作用 |
|---|---|
| `setAngle(deg)` | 停播放、清过渡、写 `bend` |
| `togglePlay()` | 自动开合;起播按当前角反算 `phase`,避免跳变 |
| `setTheme('wallpaper'|'launcher')` | 换预置 UI |
| `applyCustomImage(file)` | 自定义图并过渡到展开 |
| `dispose()` | 停循环、释放 WebGL |
Hooks:onAngle(同步滑块)、onReady、onError。
- 建议阅读顺序
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)
- 角度驱动与渲染
- 附录 A:目录结构
14.1 源码 app/src/
<span>src</span>/
├── <span>main</span><span>.js</span> 入口:<span>createApp</span>(App)<span>.mount</span>('#app')
├── App<span>.vue</span> 根组件,只渲染 <DuoViewer />
├── style<span>.css</span> 全局样式(字体、reset、焦点样式)
├── assets/ Vite 模板静态资源(与 <span>3</span>D 无关)
├── components/
│ └── DuoViewer<span>.vue</span> 视图:viewport、滑块、播放、主题切换、生命周期
└── lib/
├── duoScene<span>.js</span> 场景主逻辑(本文件详解重点)
├── ui<span>.js</span> loadDefaultUIs:合成壁纸/桌面 <span>canvas</span>
└── shaders/
├── snippets<span>.js</span> onBeforeCompile 注入短字符串 + injectScreenShader
├── fold-cover<span>.glsl</span> 盖板刚体:rotateHinge
├── fold-flexible<span>.glsl</span> 柔性带:bendStrip(Hermite)
├── screen-inner<span>.glsl</span> 内屏片元:投影 + 进度模糊/压暗
└── screen-outer<span>.glsl</span> 外屏片元:铰链边锚定 UV + 模糊/压暗
14.2 资源 app/public/assets/
<span>public</span>/assets/
├── iPhone_Duo_Render.usdc 官方机模(prim 名即 duoScene 白名单 ID 的来源)
├── iPhone_Duo_Star_White.usdz 备用机模(当前代码未引用)
├── textures/ 机身外观贴图(约 <span>50</span> 张,乱码名由 USD 内部引用)
│ ├── KJqLujGRCuLHVMP.png
│ ├── FusPnwQfcVrhWbQ.png
│ └── …
└── ui/ 屏幕内容(ui.js 消费)
├── wallpaper-<span>inner</span>.avif 内屏壁纸底图(外屏由它裁切合成)
├── clock-<span>inner</span>.avif 内屏时钟 overlay
├── clock-outer.avif 外屏时钟 overlay
├── launcher-<span>inner</span>.png 桌面截图(内屏,含边框需裁剪)
└── launcher-outer.png 桌面截图(外屏)
分工:
| 目录 | 谁用 | 何时加载 |
|---|---|---|
| `textures/` | USDLoader(自动) | 加载机模时按 USD 内引用 |
| `ui/` | `ui.js → loadDefaultUIs()` | 场景初始化,合成后转 CanvasTexture |
- 源码清单
15.1 src/lib/ui.js
<span>/** Load default Wallpaper / Launcher screen canvases from public/assets/ui */</span>
<span>export</span> <span>async</span> <span>function</span> <span>loadDefaultUIs</span>(<span></span>) {
<span>const</span> names = [
<span>'wallpaper-inner.avif'</span>,
<span>'clock-inner.avif'</span>,
<span>'clock-outer.avif'</span>,
<span>'launcher-inner.png'</span>,
<span>'launcher-outer.png'</span>,
]
<span>const</span> images = <span>Object</span>.<span>fromEntries</span>(
<span>await</span> <span>Promise</span>.<span>all</span>(
names.<span>map</span>(<span>async</span> (name) => {
<span>const</span> image = <span>new</span> <span>Image</span>()
image.<span>src</span> = <span>`/assets/ui/<span>${name}</span>`</span>
<span>await</span> image.<span>decode</span>()
<span>return</span> [name, image]
}),
),
)
<span>const</span> themes = { <span>wallpaper</span>: {}, <span>launcher</span>: {} }
<span>const</span> innerWidth = <span>1600</span>
<span>for</span> (<span>const</span> [theme, screens] <span>of</span> <span>Object</span>.<span>entries</span>(themes)) {
<span>for</span> (<span>const</span> kind <span>of</span> [<span>'inner'</span>, <span>'outer'</span>]) {
<span>const</span> canvas = <span>document</span>.<span>createElement</span>(<span>'canvas'</span>)
canvas.<span>width</span> = kind === <span>'inner'</span> ? innerWidth : <span>774</span>
canvas.<span>height</span> = <span>1125</span>
<span>const</span> context = canvas.<span>getContext</span>(<span>'2d'</span>)
<span>if</span> (theme === <span>'wallpaper'</span>) {
context.<span>drawImage</span>(images[<span>'wallpaper-inner.avif'</span>], canvas.<span>width</span> - innerWidth, <span>0</span>, innerWidth, canvas.<span>height</span>)
context.<span>drawImage</span>(images[<span>`clock-<span>${kind}</span>.avif`</span>], <span>0</span>, <span>0</span>, canvas.<span>width</span>, canvas.<span>height</span>)
} <span>else</span> {
<span>const</span> crop = kind === <span>'inner'</span> ? [<span>22</span>, <span>22</span>, <span>1072</span>, <span>754</span>] : [<span>28</span>, <span>16</span>, <span>510</span>, <span>742</span>]
context.<span>drawImage</span>(images[<span>`launcher-<span>${kind}</span>.png`</span>], ...crop, <span>0</span>, <span>0</span>, canvas.<span>width</span>, canvas.<span>height</span>)
}
screens[kind] = canvas
}
}
<span>return</span> themes
}
15.2 src/lib/shaders/snippets.js
<span>/**
* Three.js onBeforeCompile 注入用的短片段(普通字符串,非模板字面量)
*/</span>
<span>export</span> <span>const</span> <span>VARYING_UI_POSITION</span> = <span>'varying vec3 vUIPosition;\n'</span>
<span>export</span> <span>const</span> <span>PROJECT_VERTEX_WITH_UI</span> =
<span>'vUIPosition = transformed;\n'</span> +
<span>'#include <project_vertex>\n'</span>
<span>export</span> <span>const</span> <span>BEGIN_VERTEX_COVER</span> =
<span>'vec2 folded = rotateHinge(position.xz);\n'</span> +
<span>'vec3 transformed = vec3(folded.x, position.y, folded.y);\n'</span>
<span>export</span> <span>const</span> <span>BEGIN_VERTEX_FLEXIBLE</span> =
<span>'vec4 folded = bendStrip(position);\n'</span> +
<span>'vec3 transformed = vec3(folded.x, position.y, folded.y);\n'</span>
<span>export</span> <span>const</span> <span>BEGIN_NORMAL_COVER</span> =
<span>'vec3 objectNormal = vec3(normal);\n'</span> +
<span>'float a = foldAngle;\n'</span> +
<span>'objectNormal.x = cos(a) * normal.x + sin(a) * normal.z;\n'</span> +
<span>'objectNormal.z = -sin(a) * normal.x + cos(a) * normal.z;\n'</span>
<span>export</span> <span>const</span> <span>BEGIN_NORMAL_FLEXIBLE</span> =
<span>'vec3 objectNormal = vec3(normal);\n'</span> +
<span>'vec4 strip = bendStrip(position);\n'</span> +
<span>'float a = atan(-strip.w, strip.z);\n'</span> +
<span>'objectNormal.x = cos(a) * normal.x + sin(a) * normal.z;\n'</span> +
<span>'objectNormal.z = -sin(a) * normal.x + cos(a) * normal.z;\n'</span>
<span>export</span> <span>const</span> <span>MAP_FRAGMENT_SCREEN</span> = <span>'diffuseColor.rgb *= screenColor();\n'</span>
<span>/** 把 screenColor() 拼进标准 map_pars_fragment */</span>
<span>export</span> <span>function</span> <span>injectScreenShader</span>(<span>fragmentShader, screenGlsl</span>) {
<span>return</span> fragmentShader
.<span>replace</span>(
<span>'#include <map_pars_fragment>'</span>,
<span>'#include <map_pars_fragment>\n'</span> + screenGlsl,
)
.<span>replace</span>(<span>'#include <map_fragment>'</span>, <span>MAP_FRAGMENT_SCREEN</span>)
}
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/
<span>/**
* 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 * π
*/</span>
<span>import</span> * <span>as</span> <span>THREE</span> <span>from</span> <span>'three'</span>
<span>import</span> { <span>USDLoader</span> } <span>from</span> <span>'three/addons/loaders/USDLoader.js'</span>
<span>import</span> { <span>OrbitControls</span> } <span>from</span> <span>'three/addons/controls/OrbitControls.js'</span>
<span>import</span> { <span>RoomEnvironment</span> } <span>from</span> <span>'three/addons/environments/RoomEnvironment.js'</span>
<span>import</span> { loadDefaultUIs } <span>from</span> <span>'./ui.js'</span>
<span>import</span> foldCoverGlsl <span>from</span> <span>'./shaders/fold-cover.glsl?raw'</span>
<span>import</span> foldFlexibleGlsl <span>from</span> <span>'./shaders/fold-flexible.glsl?raw'</span>
<span>import</span> screenInnerGlsl <span>from</span> <span>'./shaders/screen-inner.glsl?raw'</span>
<span>import</span> screenOuterGlsl <span>from</span> <span>'./shaders/screen-outer.glsl?raw'</span>
<span>import</span> {
<span>BEGIN_NORMAL_COVER</span>,
<span>BEGIN_NORMAL_FLEXIBLE</span>,
<span>BEGIN_VERTEX_COVER</span>,
<span>BEGIN_VERTEX_FLEXIBLE</span>,
injectScreenShader,
<span>PROJECT_VERTEX_WITH_UI</span>,
<span>VARYING_UI_POSITION</span>,
} <span>from</span> <span>'./shaders/snippets.js'</span>
<span>/**
* 在 viewport 内创建 Duo 场景,返回命令式 API(不绑定 Vue 响应式)。
*
* <span>@param</span> {<span>HTMLElement</span>} viewport 放置 WebGL canvas 的容器
* <span>@param</span> {<span>{
* onAngle?: (deg: number) => void,
* onReady?: (ok: boolean) => void,
* onError?: (err: Error) => void,
* </span>}} [hooks]
*/</span>
<span>export</span> <span>function</span> <span>createDuoScene</span>(<span>viewport, hooks = {}</span>) {
<span>// ---------------------------------------------------------------------------</span>
<span>// 场景 / 相机 / 渲染器</span>
<span>// ---------------------------------------------------------------------------</span>
<span>const</span> scene = <span>new</span> <span>THREE</span>.<span>Scene</span>()
<span>const</span> camera = <span>new</span> <span>THREE</span>.<span>PerspectiveCamera</span>(<span>32</span>, <span>1</span>, <span>0.1</span>, <span>250</span>)
<span>// 与 uiReferenceEye 同位置:屏幕 shader 用固定眼睛做投影,Orbit 只影响观看视角</span>
camera.<span>position</span>.<span>set</span>(<span>0</span>, <span>0</span>, <span>40</span>)
<span>const</span> renderer = <span>new</span> <span>THREE</span>.<span>WebGLRenderer</span>({ <span>antialias</span>: <span>true</span>, <span>alpha</span>: <span>true</span> })
renderer.<span>setPixelRatio</span>(<span>Math</span>.<span>min</span>(devicePixelRatio, <span>2</span>))
renderer.<span>setClearColor</span>(<span>0xf6f6f3</span>, <span>0</span>)
renderer.<span>toneMapping</span> = <span>THREE</span>.<span>ACESFilmicToneMapping</span>
renderer.<span>toneMappingExposure</span> = <span>1.18</span>
viewport.<span>appendChild</span>(renderer.<span>domElement</span>)
<span>// ---------------------------------------------------------------------------</span>
<span>// 环境光 / 反射(金属框、玻璃)</span>
<span>// ---------------------------------------------------------------------------</span>
<span>const</span> environment = <span>new</span> <span>RoomEnvironment</span>() <span>//光照房间</span>
<span>const</span> pmrem = <span>new</span> <span>THREE</span>.<span>PMREMGenerator</span>(renderer)
scene.<span>environment</span> = pmrem.<span>fromScene</span>(environment, <span>0.04</span>).<span>texture</span>
environment.<span>dispose</span>()
pmrem.<span>dispose</span>()
scene.<span>environmentIntensity</span> = <span>1.35</span>
scene.<span>add</span>(<span>new</span> <span>THREE</span>.<span>HemisphereLight</span>(<span>0xffffff</span>, <span>0xb5baa8</span>, <span>1.8</span>))
<span>const</span> key = <span>new</span> <span>THREE</span>.<span>DirectionalLight</span>(<span>0xfffcf5</span>, <span>2.6</span>)
key.<span>position</span>.<span>set</span>(-<span>15</span>, <span>25</span>, <span>30</span>)
scene.<span>add</span>(key)
<span>const</span> rim = <span>new</span> <span>THREE</span>.<span>DirectionalLight</span>(<span>0xe8edf5</span>, <span>2</span>)
rim.<span>position</span>.<span>set</span>(<span>15</span>, <span>5</span>, -<span>15</span>)
scene.<span>add</span>(rim)
<span>// 轨道控制:绕铰链附近旋转,禁止平移,避免机模飞出构图</span>
<span>const</span> controls = <span>new</span> <span>OrbitControls</span>(camera, renderer.<span>domElement</span>)
controls.<span>enableDamping</span> = <span>true</span>
controls.<span>enablePan</span> = <span>false</span>
controls.<span>minDistance</span> = <span>21</span>
controls.<span>maxDistance</span> = <span>65</span>
controls.<span>target</span>.<span>set</span>(<span>0</span>, <span>0</span>, <span>0.275454</span>) <span>// 铰链轴附近</span>
controls.<span>update</span>()
<span>/** 所有拆平后的机模 mesh 挂在此 Group 下 */</span>
<span>const</span> phone = <span>new</span> <span>THREE</span>.<span>Group</span>()
scene.<span>add</span>(phone)
<span>// ---------------------------------------------------------------------------</span>
<span>// 折叠 / 播放状态</span>
<span>// ---------------------------------------------------------------------------</span>
<span>/** GPU uniform:foldAngle(弧度)。改 .value 即驱动所有 fold 着色器 */</span>
<span>const</span> bend = { <span>value</span>: <span>0</span> }
<span>/** UI 开合角(度),180 展开 / 0 合上 */</span>
<span>let</span> angle = <span>180</span>
<span>let</span> playing = <span>false</span>
<span>/** 自动播放时间轴相位,周期 8.6s */</span>
<span>let</span> phase = <span>0</span>
<span>/** 平滑过渡:{ from, to, elapsed },自定义图时收到 180° */</span>
<span>let</span> transition = <span>null</span>
<span>let</span> ready = <span>false</span>
<span>let</span> disposed = <span>false</span>
<span>/**
* 内外屏运行时状态:
* material / defaultTextures / frame / gradient / pixel(均为 shader uniforms 包装)
*/</span>
<span>const</span> screens = {}
<span>/**
* 屏幕投影用的「固定眼睛」,刻意与 Orbit 解耦:
* 转视角时 UI 仍保持广告正面投影,不会贴在弯曲表面上扭歪。
*/</span>
<span>const</span> uiReferenceEye = <span>new</span> <span>THREE</span>.<span>Vector3</span>(<span>0</span>, <span>0</span>, <span>40</span>)
<span>/**
* 展开平面上的 UI 矩形:xy = 左下角,zw = 宽高(模型坐标,已含 Y 平移修正)。
* 与下方重建 UV、screen-*.glsl 中的常数同源,改模型后需一起校准。
*/</span>
<span>const</span> innerUIFrame = <span>new</span> <span>THREE</span>.<span>Vector4</span>(-<span>7.89935</span>, <span>0.34562</span> - <span>5.8974</span>, <span>15.7987</span>, <span>11.1035</span>)
<span>// 外屏物理深度不同,按透视比例缩放 frame,使投影尺度与内屏对齐</span>
<span>const</span> outerUIFrame = <span>new</span> <span>THREE</span>.<span>Vector4</span>(<span>0.23396</span>, <span>0.27173</span> - <span>5.8974</span>, <span>7.73936</span>, <span>11.2513</span>).<span>multiplyScalar</span>(
(uiReferenceEye.<span>z</span> - <span>0.24948</span>) / (uiReferenceEye.<span>z</span> - <span>0.825538</span>),
)
<span>// ---------------------------------------------------------------------------</span>
<span>// 屏幕贴图(默认主题 + 自定义上传共用 canvas)</span>
<span>// ---------------------------------------------------------------------------</span>
<span>let</span> uiTheme = <span>'wallpaper'</span>
<span>const</span> uiCanvas = <span>document</span>.<span>createElement</span>(<span>'canvas'</span>)
uiCanvas.<span>width</span> = <span>1600</span>
uiCanvas.<span>height</span> = <span>1125</span>
<span>const</span> uiTexture = <span>new</span> <span>THREE</span>.<span>CanvasTexture</span>(uiCanvas)
uiTexture.<span>colorSpace</span> = <span>THREE</span>.<span>SRGBColorSpace</span>
uiTexture.<span>anisotropy</span> = renderer.<span>capabilities</span>.<span>getMaxAnisotropy</span>()
<span>/**
* 写入开合角:同步 bend(GPU)与外屏显隐。
* angle≥180 时外屏乘黑,避免展开态外屏仍发亮。
*/</span>
<span>function</span> <span>setAngle</span>(<span>value</span>) {
angle = value
bend.<span>value</span> = ((<span>180</span> - value) / <span>180</span>) * <span>Math</span>.<span>PI</span>
<span>if</span> (screens.<span>outer</span>?.<span>material</span>) {
screens.<span>outer</span>.<span>material</span>.<span>color</span>.<span>setScalar</span>(value >= <span>180</span> ? <span>0</span> : <span>1</span>)
}
hooks.<span>onAngle</span>?.(value)
}
<span>function</span> <span>setPlaying</span>(<span>value</span>) {
playing = value
}
<span>/** 把 wallpaper / launcher 主题纹理绑到内外屏,并刷新 shader 用的 frame / gradient / pixel */</span>
<span>function</span> <span>showDefaultUI</span>(<span></span>) {
<span>for</span> (<span>const</span> [kind, screen] <span>of</span> <span>Object</span>.<span>entries</span>(screens)) {
<span>const</span> texture = screen.<span>defaultTextures</span>[uiTheme]
screen.<span>material</span>.<span>map</span> = texture
screen.<span>pixel</span>.<span>value</span>.<span>set</span>(<span>1</span> / texture.<span>image</span>.<span>width</span>, <span>1</span> / texture.<span>image</span>.<span>height</span>)
screen.<span>frame</span>.<span>value</span>.<span>copy</span>(kind === <span>'inner'</span> ? innerUIFrame : outerUIFrame)
<span>// inner: 模糊从中缝(0.5)往一侧扫;outer: 从铰链边(0)往另一侧</span>
screen.<span>gradient</span>.<span>value</span>.<span>set</span>(kind === <span>'inner'</span> ? <span>0.5</span> : <span>0</span>, kind === <span>'inner'</span> ? <span>0</span> : <span>1</span>)
}
}
<span>function</span> <span>setTheme</span>(<span>theme</span>) {
<span>if</span> (theme === <span>'custom'</span>) <span>return</span>
uiTheme = theme
<span>showDefaultUI</span>()
}
<span>/** 用户上传图片:居中铺到 uiCanvas,内外屏共用,并过渡到展开角 */</span>
<span>async</span> <span>function</span> <span>applyCustomImage</span>(<span>file</span>) {
<span>const</span> url = <span>URL</span>.<span>createObjectURL</span>(file)
<span>const</span> img = <span>new</span> <span>Image</span>()
img.<span>src</span> = url
<span>try</span> {
<span>await</span> img.<span>decode</span>()
<span>const</span> c = uiCanvas.<span>getContext</span>(<span>'2d'</span>)
c.<span>fillStyle</span> = <span>'#101418'</span>
c.<span>fillRect</span>(<span>0</span>, <span>0</span>, uiCanvas.<span>width</span>, uiCanvas.<span>height</span>)
<span>const</span> scale = <span>Math</span>.<span>min</span>(uiCanvas.<span>width</span> / img.<span>width</span>, uiCanvas.<span>height</span> / img.<span>height</span>)
<span>const</span> width = img.<span>width</span> * scale
<span>const</span> height = img.<span>height</span> * scale
c.<span>drawImage</span>(img, (uiCanvas.<span>width</span> - width) / <span>2</span>, (uiCanvas.<span>height</span> - height) / <span>2</span>, width, height)
uiTexture.<span>needsUpdate</span> = <span>true</span>
<span>for</span> (<span>const</span> [kind, screen] <span>of</span> <span>Object</span>.<span>entries</span>(screens)) {
screen.<span>material</span>.<span>map</span> = uiTexture
screen.<span>pixel</span>.<span>value</span>.<span>set</span>(<span>1</span> / uiCanvas.<span>width</span>, <span>1</span> / uiCanvas.<span>height</span>)
screen.<span>frame</span>.<span>value</span>.<span>copy</span>(innerUIFrame)
screen.<span>gradient</span>.<span>value</span>.<span>set</span>(<span>0.5</span>, kind === <span>'inner'</span> ? <span>0</span> : <span>1</span>)
}
uiTheme = <span>'custom'</span>
<span>setPlaying</span>(<span>false</span>)
transition = { <span>from</span>: angle, <span>to</span>: <span>180</span>, <span>elapsed</span>: <span>0</span> }
} <span>finally</span> {
<span>URL</span>.<span>revokeObjectURL</span>(url)
}
}
<span>/**
* 按视口反推 FOV,使机模在不同窗口高度下视觉尺度接近。
* pixelsPerUnit 限制了「每世界单位对应多少像素」。
*/</span>
<span>function</span> <span>resize</span>(<span></span>) {
<span>if</span> (disposed) <span>return</span>
<span>const</span> { width, height } = viewport.<span>getBoundingClientRect</span>()
<span>if</span> (width < <span>1</span> || height < <span>1</span>) <span>return</span>
renderer.<span>setSize</span>(width, height)
camera.<span>aspect</span> = width / height
<span>const</span> pixelsPerUnit = <span>Math</span>.<span>min</span>(width / <span>25</span>, height / <span>17</span>, <span>37</span>)
camera.<span>fov</span> = <span>THREE</span>.<span>MathUtils</span>.<span>radToDeg</span>(<span>2</span> * <span>Math</span>.<span>atan</span>(height / pixelsPerUnit / <span>2</span> / <span>40</span>))
camera.<span>updateProjectionMatrix</span>()
}
<span>const</span> ro = <span>new</span> <span>ResizeObserver</span>(resize)
ro.<span>observe</span>(viewport)
<span>// ---------------------------------------------------------------------------</span>
<span>// 渲染循环:播放曲线 / 过渡插值 / Orbit / draw</span>
<span>// ---------------------------------------------------------------------------</span>
<span>let</span> lastTime = performance.<span>now</span>()
<span>/**
* 每帧回调(由 WebGLRenderer 驱动,约等于 requestAnimationFrame)。
* 优先级:playing 自动开合 > transition 平滑过渡 > 仅渲染(滑块已在外部 setAngle)。
*/</span>
renderer.<span>setAnimationLoop</span>(<span>(<span>now</span>) =></span> {
<span>if</span> (disposed) <span>return</span>
<span>// delta:本帧间隔(秒)。上限 0.05 避免切后台回来一帧跳太大导致动画瞬移</span>
<span>const</span> delta = <span>Math</span>.<span>min</span>((now - lastTime) / <span>1000</span>, <span>0.05</span>)
lastTime = now
<span>if</span> (ready && playing) {
<span>/**
* 自动演示时间轴(总长 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,避免半开处跳变。
*/</span>
phase = (phase + delta) % <span>8.6</span>
<span>let</span> value
<span>if</span> (phase < <span>1.2</span>) {
value = <span>180</span>
} <span>else</span> <span>if</span> (phase < <span>4.3</span>) {
<span>// t = (phase-1.2)/3.1 ∈ [0,1]:展开 → 合上</span>
value = <span>90</span> * (<span>1</span> + <span>Math</span>.<span>cos</span>(((phase - <span>1.2</span>) / <span>3.1</span>) * <span>Math</span>.<span>PI</span>))
} <span>else</span> <span>if</span> (phase < <span>5.5</span>) {
value = <span>0</span>
} <span>else</span> {
<span>// t = (phase-5.5)/3.1 ∈ [0,1]:合上 → 展开</span>
value = <span>90</span> * (<span>1</span> - <span>Math</span>.<span>cos</span>(((phase - <span>5.5</span>) / <span>3.1</span>) * <span>Math</span>.<span>PI</span>))
}
<span>setAngle</span>(value) <span>// 内部写 bend.value,驱动 fold / screen shader</span>
} <span>else</span> <span>if</span> (transition) {
<span>/**
* 单次平滑过渡(如自定义图后收到 180°)。
* progress ∈ [0,1] 线性时间;ease = smoothstep(progress) 两端减速。
* 结束后清空 transition,下一帧不再进此分支。
*/</span>
transition.<span>elapsed</span> += delta
<span>const</span> progress = <span>Math</span>.<span>min</span>(transition.<span>elapsed</span> / <span>1.4</span>, <span>1</span>)
<span>const</span> ease = progress * progress * (<span>3</span> - <span>2</span> * progress) <span>// Hermite smoothstep</span>
<span>setAngle</span>(<span>THREE</span>.<span>MathUtils</span>.<span>lerp</span>(transition.<span>from</span>, transition.<span>to</span>, ease))
<span>if</span> (progress === <span>1</span>) transition = <span>null</span>
}
<span>// else:既不播放也不过渡 → angle 保持上次值(用户拖滑块已直接 setAngle)</span>
controls.<span>update</span>() <span>// 阻尼惯性,需每帧调</span>
renderer.<span>render</span>(scene, camera)
})
<span>// ---------------------------------------------------------------------------</span>
<span>// 异步初始化:UI 纹理 → USD 机模 → 分类 mesh → 注入着色器</span>
<span>// ---------------------------------------------------------------------------</span>
;(<span>async</span> () => {
<span>try</span> {
<span>// --- 1) 预生成内外屏 CanvasTexture(wallpaper / launcher)---</span>
<span>const</span> defaultUIs = <span>await</span> <span>loadDefaultUIs</span>()
<span>for</span> (<span>const</span> kind <span>of</span> [<span>'inner'</span>, <span>'outer'</span>]) {
<span>const</span> defaultTextures = {}
<span>for</span> (<span>const</span> [theme, canvases] <span>of</span> <span>Object</span>.<span>entries</span>(defaultUIs)) {
<span>const</span> texture = <span>new</span> <span>THREE</span>.<span>CanvasTexture</span>(canvases[kind])
texture.<span>colorSpace</span> = <span>THREE</span>.<span>SRGBColorSpace</span>
texture.<span>anisotropy</span> = renderer.<span>capabilities</span>.<span>getMaxAnisotropy</span>()
defaultTextures[theme] = texture
}
<span>// toneMapped: false —— 屏幕内容不做 ACES,保持 UI 原色</span>
<span>const</span> material = <span>new</span> <span>THREE</span>.<span>MeshBasicMaterial</span>({ <span>map</span>: defaultTextures[uiTheme], <span>toneMapped</span>: <span>false</span> })
screens[kind] = {
material,
defaultTextures,
<span>frame</span>: { <span>value</span>: (kind === <span>'inner'</span> ? innerUIFrame : outerUIFrame).<span>clone</span>() },
<span>gradient</span>: { <span>value</span>: <span>new</span> <span>THREE</span>.<span>Vector2</span>(kind === <span>'inner'</span> ? <span>0.5</span> : <span>0</span>, kind === <span>'inner'</span> ? <span>0</span> : <span>1</span>) },
<span>pixel</span>: {
<span>value</span>: <span>new</span> <span>THREE</span>.<span>Vector2</span>(
<span>1</span> / defaultUIs[uiTheme][kind].<span>width</span>,
<span>1</span> / defaultUIs[uiTheme][kind].<span>height</span>,
),
},
}
}
<span>console</span>.<span>log</span>(<span>'内外屏'</span>,screens)
<span>// --- 2) 加载官方 USDC,烘焙世界矩阵,Y 平移对齐铰链 ---</span>
<span>const</span> model = <span>await</span> <span>new</span> <span>USDLoader</span>().<span>loadAsync</span>(<span>'/assets/iPhone_Duo_Render.usdc'</span>)
model.<span>scale</span>.<span>multiplyScalar</span>(<span>100</span>)
model.<span>updateMatrixWorld</span>(<span>true</span>)
<span>// --- 3) 遍历每个 mesh:分类 + 可选重建 UV + 注入 fold/screen shader ---</span>
model.<span>traverse</span>(<span>(<span>object</span>) =></span> {
<span>if</span> (!object.<span>isMesh</span>) <span>return</span>
<span>console</span>.<span>log</span>(<span>'object'</span>,object)
<span>// 拆成独立几何(不再依赖 USD 层级矩阵),整体下移对齐铰链</span>
<span>const</span> geometry = object.<span>geometry</span>.<span>clone</span>().<span>applyMatrix4</span>(object.<span>matrixWorld</span>)
geometry.<span>translate</span>(<span>0</span>, -<span>5.8974</span>, <span>0</span>)
<span>// 沿父节点向上找半机归属:</span>
<span>// upTUAKvMVkPOMKq = 盖板侧(moving)</span>
<span>// SiftyleUEEZwLhF = 后摄侧(fixed,不注入 fold)</span>
<span>let</span> ancestor = object
<span>while</span> (ancestor && ![<span>'upTUAKvMVkPOMKq'</span>, <span>'SiftyleUEEZwLhF'</span>].<span>includes</span>(ancestor.<span>name</span>)) {
ancestor = ancestor.<span>parent</span>
}
<span>const</span> moving = ancestor?.<span>name</span> === <span>'upTUAKvMVkPOMKq'</span>
<span>// 铰链附近柔性 mesh(含内屏):走 bendStrip,否则盖板走 rotateHinge</span>
<span>const</span> flexible = [<span>'JnJdTkxbQgUtLwU'</span>, <span>'xdyyaajWsatVNxN'</span>, <span>'UXtsBZYlaUvHoEh'</span>, <span>'MvKPXGSdYDVvSpk'</span>].<span>includes</span>(
object.<span>name</span>,
)
<span>// 屏幕 mesh 名 → 绑定 screens.inner / screens.outer</span>
<span>const</span> kind =
object.<span>name</span> === <span>'UXtsBZYlaUvHoEh'</span> ? <span>'inner'</span> : object.<span>name</span> === <span>'hhgAIoCGsHXeDPY'</span> ? <span>'outer'</span> : <span>null</span>
<span>const</span> material = kind ? screens[kind].<span>material</span> : object.<span>material</span>.<span>clone</span>()
<span>// 屏幕:用世界坐标归一化重建 UV,供基础 map;真正取色仍以 screen shader 投影为主</span>
<span>if</span> (kind) {
<span>const</span> p = geometry.<span>attributes</span>.<span>position</span>
<span>const</span> uv = <span>new</span> <span>Float32Array</span>(p.<span>count</span> * <span>2</span>)
<span>for</span> (<span>let</span> i = <span>0</span>; i < p.<span>count</span>; i++) {
uv[i * <span>2</span>] =
kind === <span>'inner'</span> ? (p.<span>getX</span>(i) + <span>7.89935</span>) / <span>15.7987</span> : (-<span>0.23396</span> - p.<span>getX</span>(i)) / <span>7.73936</span>
uv[i * <span>2</span> + <span>1</span>] =
kind === <span>'inner'</span>
? (p.<span>getY</span>(i) + <span>5.8974</span> - <span>0.34562</span>) / <span>11.1035</span>
: (p.<span>getY</span>(i) + <span>5.8974</span> - <span>0.27173</span>) / <span>11.2513</span>
}
geometry.<span>setAttribute</span>(<span>'uv'</span>, <span>new</span> <span>THREE</span>.<span>BufferAttribute</span>(uv, <span>2</span>))
}
<span>// 盖板 / 柔性:编译前改写标准材质的 vertex(折)+ fragment(屏)</span>
<span>if</span> (moving || flexible) {
<span>debugger</span>
material.<span>onBeforeCompile</span> = <span>(<span>shader</span>) =></span> {
<span>// 共享同一个 bend 对象引用,滑块改 angle 即全局生效</span>
shader.<span>uniforms</span>.<span>foldAngle</span> = bend
<span>if</span> (kind) {
<span>// 注入 screen-inner / screen-outer:投影 UV + 模糊</span>
shader.<span>uniforms</span>.<span>uiFrame</span> = screens[kind].<span>frame</span>
shader.<span>uniforms</span>.<span>uiGradient</span> = screens[kind].<span>gradient</span>
shader.<span>uniforms</span>.<span>uiReferenceEye</span> = { <span>value</span>: uiReferenceEye }
shader.<span>uniforms</span>.<span>uiPixel</span> = screens[kind].<span>pixel</span>
<span>const</span> screenGlsl = kind === <span>'inner'</span> ? screenInnerGlsl : screenOuterGlsl
shader.<span>fragmentShader</span> = <span>injectScreenShader</span>(shader.<span>fragmentShader</span>, screenGlsl)
<span>// 变形后的位置传给片元做投影</span>
shader.<span>vertexShader</span> = <span>VARYING_UI_POSITION</span> + shader.<span>vertexShader</span>
shader.<span>vertexShader</span> = shader.<span>vertexShader</span>.<span>replace</span>(
<span>'#include <project_vertex>'</span>,
<span>PROJECT_VERTEX_WITH_UI</span>,
)
}
<span>// 顶点折叠:柔性用 bendStrip,盖板刚体用 rotateHinge</span>
<span>const</span> foldGlsl = flexible ? foldFlexibleGlsl : foldCoverGlsl
shader.<span>vertexShader</span> = foldGlsl + <span>'\n'</span> + shader.<span>vertexShader</span>
shader.<span>vertexShader</span> = shader.<span>vertexShader</span>.<span>replace</span>(
<span>'#include <begin_vertex>'</span>,
flexible ? <span>BEGIN_VERTEX_FLEXIBLE</span> : <span>BEGIN_VERTEX_COVER</span>,
)
<span>// 法线同步旋转,保证折后光照正确</span>
shader.<span>vertexShader</span> = shader.<span>vertexShader</span>.<span>replace</span>(
<span>'#include <beginnormal_vertex>'</span>,
flexible ? <span>BEGIN_NORMAL_FLEXIBLE</span> : <span>BEGIN_NORMAL_COVER</span>,
)
}
<span>// 不同变体必须不同 cache key,否则 Three 会串用编译好的 program</span>
material.<span>customProgramCacheKey</span> = <span>() =></span>
(flexible ? <span>'fold-flexible'</span> : <span>'fold-cover'</span>) + <span>'-'</span> + (kind || <span>'body'</span>)
}
<span>const</span> mesh = <span>new</span> <span>THREE</span>.<span>Mesh</span>(geometry, material)
mesh.<span>name</span> = object.<span>name</span>
<span>// 大角度折叠时包围盒不准,关闭视锥裁剪以免半机消失</span>
mesh.<span>frustumCulled</span> = <span>false</span>
phone.<span>add</span>(mesh)
})
<span>showDefaultUI</span>()
ready = <span>true</span>
<span>setAngle</span>(<span>180</span>)
<span>resize</span>()
hooks.<span>onReady</span>?.(<span>true</span>)
} <span>catch</span> (error) {
<span>console</span>.<span>error</span>(error)
hooks.<span>onError</span>?.(error <span>instanceof</span> <span>Error</span> ? error : <span>new</span> <span>Error</span>(<span>String</span>(error)))
hooks.<span>onReady</span>?.(<span>false</span>)
}
})()
<span>// ---------------------------------------------------------------------------</span>
<span>// 对外 API(供 DuoViewer.vue 调用)</span>
<span>// ---------------------------------------------------------------------------</span>
<span>return</span> {
<span>getAngle</span>: <span>() =></span> angle,
<span>getPlaying</span>: <span>() =></span> playing,
<span>isReady</span>: <span>() =></span> ready,
<span>/** 手动拖滑块:停播放、清过渡 */</span>
<span>setAngle</span>: <span>(<span>v</span>) =></span> {
transition = <span>null</span>
<span>setPlaying</span>(<span>false</span>)
<span>setAngle</span>(v)
},
<span>/**
* 开关自动演示。起播时按当前 angle 反算 phase,
* 避免从半开位置直接跳到时间轴起点。
*/</span>
<span>togglePlay</span>: <span>() =></span> {
transition = <span>null</span>
<span>if</span> (!playing) phase = <span>1.2</span> + (<span>Math</span>.<span>acos</span>((<span>2</span> * angle) / <span>180</span> - <span>1</span>) / <span>Math</span>.<span>PI</span>) * <span>3.1</span>
<span>setPlaying</span>(!playing)
<span>return</span> playing
},
setPlaying,
setTheme,
applyCustomImage,
<span>/** 卸载:停循环、释放 WebGL / controls */</span>
<span>dispose</span>: <span>() =></span> {
disposed = <span>true</span>
ro.<span>disconnect</span>()
renderer.<span>setAnimationLoop</span>(<span>null</span>)
controls.<span>dispose</span>()
renderer.<span>dispose</span>()
<span>if</span> (renderer.<span>domElement</span>.<span>parentNode</span> === viewport) {
viewport.<span>removeChild</span>(renderer.<span>domElement</span>)
}
},
}
}
- 相关文档
- 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…
适合做 Three.js 折叠屏、自定义材质与 USD 模型加载的开发者参考,弯折变形与屏幕投影通过 varying 串联的思路,可直接迁移到 3D 产品演示与交互动效。