three-base 2.0.197 → 2.0.198

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -121,6 +121,12 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
121
121
  /** 当前全景纹理形态(只读)。消费方(预览立方体等自持材质的路径)据此算 `mktPanoTiledFlag`,
122
122
  * 必须与基类喂纹理处用同一个值,否则标记与地址不同源 ⇒ 整图被按 1/2 横向铺满球面(10-06 晚起纵向不分块,四槽时代是 1/4)。 */
123
123
  get panoTexMode(): PanoTexMode;
124
+ /** 优先加载的全景个数(存档 `textures` 的前 n 项):**-1 = 全部、0 = 一个都不提前、n = 前 n 个**(2026-11-05 需求)。
125
+ * 默认取 `PANO_IMPORT_PRIORITY_DEFAULT`(=4);宿主想换档在渲染循环跑起来之前 `setPanoPriorityCount(-1)` 一行即可
126
+ * (与 `_ossSize`、`_panoTexMode` 同一套注入路径 ⇒ 编辑器与预览各自的配置各自推下来)。 */
127
+ private _panoPriorityCount;
128
+ /** 当前优先加载个数(只读,回显用)。 */
129
+ get panoPriorityCount(): number;
124
130
  private _tmpV3;
125
131
  /**
126
132
  * 图片平面层(⭐ 10-26 旨入库):对象挂在**本 MktMesh 的子树**里,⛔ 不是场景根
@@ -338,6 +344,11 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
338
344
  * 下一帧 `feedFaceMapping` 里共用的那份 binder 见「槽内现有形态 ≠ 目标形态」自动走整档判齐、一次换绑并同帧翻标记。
339
345
  * 编辑器 MktDevMesh 走的是自己那路(同一个 binder、同一份策略),本方法对它只是记下形态。 */
340
346
  setPanoTexMode(mode: PanoTexMode): void;
347
+ /** 设置优先加载的全景个数:-1 = 全部 / 0 = 不提前 / n = 存档前 n 个(2026-11-05 需求)。
348
+ * 收口:负数(含 -2、NaN 一类)一律归到 -1「全加载」,正数向下取整;非有限值回落默认档。
349
+ * 改档⛔ 需要重新 `importData` —— 名册每帧重排,下一帧 `prefetchHeadPanoramas` 直接用新数点名
350
+ * (`prioritize` 幂等 ⇒ 已在队列里的那批不重复投喂,也不会把已出队的旧批再塞回去)。 */
351
+ setPanoPriorityCount(count: number): void;
341
352
  /**
342
353
  * 记下「当前激活(名册 0 号槽 = 中心)是第几个全景」。
343
354
  * ⭐ 皇上旨 2026-10-03 旨②:预览立方体已与墙**共用同一份材质**,原来那句「把中心那几张抄给立方体单全景材质」
@@ -499,11 +510,12 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
499
510
  * @param onModelLoad 每只 GLB 落地时回调一次(可选)——按需渲染的宿主(编辑器)拿它标脏;每帧真画的预览不传 */
500
511
  importData(data: MktMeshData, projName?: string, onModelLoad?: () => void): void;
501
512
  /**
502
- * 把**存档里前 `PANO_IMPORT_PRIORITY` 个全景**按存档顺序登记进拼合器的**加载任务队列**
513
+ * 把**存档里前 `panoPriorityCount` 个全景**(-1 = 整份全加载)按存档顺序登记进拼合器的**加载任务队列**
503
514
  *(2026-10-29 需求「优先加载前 2 个全景素材」+ 2026-10-30 需求逐字:「给 mktmesh 内部加一个加载任务队列,
504
515
  * 把模型和平面图排在最前面,然后按照顺序,依次把前 2 个全景的顺序把瓦片加载任务添加到任务队列里面,
505
516
  * 第一个加载完了,就把这个加载任务从任务队列里面去掉,然后加载第二个;
506
- * 如果后续再按照距离计算需要加载哪个全景的时候,同样地添加到加载任务队列的后面等待加载」)。
517
+ * 如果后续再按照距离计算需要加载哪个全景的时候,同样地添加到加载任务队列的后面等待加载」
518
+ * + 2026-11-05 需求「提前加载多少个全景改由配置决定,-1 全加载 / 0 不加载 / n 前 n 个,默认 4」)。
507
519
  * 由两处渲染循环每帧点一次:基类 `onBeforeRender`(预览弹窗 / 发布页)与编辑器的 `updateFloorTexture`
508
520
  *(`MktDevMesh` 覆写了 `onBeforeRender`,只点 `syncProjInvMatrix` + `tickProgressiveReveal`,库内那句在编辑态不跑)。
509
521
  *
@@ -512,21 +524,23 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
512
524
  * (GLB 逐只 `loadModel` 不 throttle;平面那 4 片一次性 `_imgTileLoader.preload`),
513
525
  * 而全景瓦片最早也要等下一帧的 `pump` 才发第一片 ⇒ 发起次序天然是「模型 → 平面图 → 全景」,
514
526
  * ⛔ 这里不再把它们塞进队列重复管一遍(塞了就是第二把尺)。
515
- * ② 本方法把存档 0 号、1 号依次 `prioritize` 进队列 ⇒ 拼合器每帧**只喂队首那一组**,
527
+ * ② 本方法把存档 0 号起这 n 号依次 `prioritize` 进队列 ⇒ 拼合器每帧**只喂队首那一组**,
516
528
  * 0 号 32 格全落定(画进 RT,或该格本就缺地址/判死)才出队、1 号才开始发第一片。
517
529
  * 比 10-29 那版(只是把两批挪到在册顺序前面、额度仍被两批分摊)更硬:**第 1 颗独享额度,最快出全图**。
530
+ * n = -1(全加载)时这一条变成「整份存档按存档顺序串行」⇒ 距离名册插不到前面,出清速度取决于总片数。
518
531
  * ③ 名册按距离点到的其他全景走 `feedFaceMapping` 里那句 `ensure(t.tiles)`(不带队列)⇒ 只入册、
519
532
  * 排在队列后面等,队列空了才按在册顺序补料(现行口径不变)。
520
533
  * ⚠️⚠️ 为什么每帧点而⛔ 只在 `importData` 里点一记:预览每开一次都换渲染器,`_switchRenderer` 把在册组
521
534
  * 整份作废(队列里的键也在同一刻失效);只在载入那次点名等于白点。本方法点在 `pump` 之后、名册逐槽
522
- * `ensure` 之前 ⇒ 每帧把这两记任务补回队列头部(`prioritize` 幂等,已在队列里就不挪,所以 0 号恒在 1 号前)。
535
+ * `ensure` 之前 ⇒ 每帧把这几记任务补回队列头部(`prioritize` 幂等,已在队列里就不挪,所以存档次序恒不变)。
523
536
  * ⚠️ 代价如实记:
524
- * · 队列非空时**其他全景一片都不发** ⇒ 开机那几秒里,离注视点最近、但不在存档前两颗的那面墙会多灰一会儿。
537
+ * · 队列非空时**其他全景一片都不发** ⇒ 开机那几秒里,离注视点最近、但不在存档前 n 颗的那面墙会多灰一会儿。
525
538
  * · 一颗「永远下不完」的瓦片(下载失败还没累计到判死)会压住队列;判死阈值是解码失败 3 次
526
- * (`_dead`),退避重试期间队首不落定 ⇒ 第 2 颗跟着延后。真实数据里缺片是「地址为空/404」,
539
+ * (`_dead`),退避重试期间队首不落定 ⇒ 后面几颗跟着延后。真实数据里缺片是「地址为空/404」,
527
540
  * 404 走三次即判死、那一格拼合涂灰 ⇒ 队列照样出清。
528
- * · 这两颗全景若本来不在开机那一刻的名册里,它们的瓦片与拼合槽 RT 会被**提前**建出来
529
- * (现行口径每颗全景 2 槽 × 6144² ≈ 384MB 显存)—— 组在运行中不淘汰,走进这两个景点迟早要建。
541
+ * · 这批全景若本来不在开机那一刻的名册里,它们的瓦片与拼合槽 RT 会被**提前**建出来
542
+ * (现行口径每颗全景 2 槽 × 6144² ≈ 384MB 显存)—— 组在运行中不淘汰,走进这些景点迟早要建;
543
+ * ⚠️ n = -1(全加载)时这一条按存档全景总数放大,显存与在途请求都跟着涨。
530
544
  * ⚠️ 形态判据读 `this._panoTexMode`(整图形态那一档一张全景只有一张整图,走 `_texCache` 那条路,
531
545
  * 走错档就是白下 32 片)⇒ 宿主若用自己那份形态变量(编辑器的 `store.panoTexMode`),
532
546
  * 要在调 `importData` 之前先 `setPanoTexMode` 推进来,编辑器那侧已按此接线。
@@ -49,11 +49,13 @@ import { stripAssetHost, absAssetUrl } from "./assetUrl";
49
49
  // 原先各住在本站两个渲染器里一份 ⇒ 下游直接用包里的 `MktMesh` 时,存档里的 `images`/`models` 两键没人读。
50
50
  // 入库之后它们挂在 `MktMesh` **自己的子树**下,与墙同一棵变换 ⇒ 预览那三根 transform 滑杆搬得动整份展品。
51
51
  import { ImagePlane } from "./imagePlane";
52
- import { ModelSlot } from "./modelSlot";
52
+ import { ModelSlot, glbFileName } from "./modelSlot";
53
53
  import { ImagePlaneTileLoader } from "./imageTileLoader";
54
- /** 载入 JSON 时**优先**加载几个全景的素材(2026-10-29 需求「优先加载前 2 个全景图的素材,然后再加载其他的」)。
55
- * 口径 = 存档 `textures` 数组的前 n 项;其余全景照常按每帧的槽位名册(离注视点最近 8 个)排队,只是排在它们后面。 */
56
- const PANO_IMPORT_PRIORITY = 2;
54
+ /** 载入 JSON 时**优先**加载几个全景的素材的**默认档**(数值只写这一处)。
55
+ * 口径 = 存档 `textures` 数组的前 n 项(n = -1 视为整份全加载);其余全景照常按每帧的槽位名册(离注视点最近 8 个)排队,只是排在它们后面。
56
+ * 2026-10-29 需求「优先加载前 2 个全景图的素材,然后再加载其他的」立的是 2;
57
+ * 2026-11-05 需求逐字「现在要默认把全景全部都要默认加载, 现在需要通过配置来设置, 提前加载多少个全景 -1 就是全加载 0就是不加载 1就是加载个 2就提前加载2个 一次类推, 默认加载前4个」⇒ 换成 4,并让这一档可被实例覆写。 */
58
+ const PANO_IMPORT_PRIORITY_DEFAULT = 4;
57
59
  /** 隐藏期预热那一次绘制用的镜头(模块级共用:它只是个「有合法的投影矩阵可读」的载体,永不动 ⇒ ⛔ 不必每实例造一份)。 */
58
60
  const GPU_WARM_CAMERA = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
59
61
  /**
@@ -193,6 +195,14 @@ export default class MktMesh extends Mesh {
193
195
  get panoTexMode() {
194
196
  return this._panoTexMode;
195
197
  }
198
+ /** 优先加载的全景个数(存档 `textures` 的前 n 项):**-1 = 全部、0 = 一个都不提前、n = 前 n 个**(2026-11-05 需求)。
199
+ * 默认取 `PANO_IMPORT_PRIORITY_DEFAULT`(=4);宿主想换档在渲染循环跑起来之前 `setPanoPriorityCount(-1)` 一行即可
200
+ * (与 `_ossSize`、`_panoTexMode` 同一套注入路径 ⇒ 编辑器与预览各自的配置各自推下来)。 */
201
+ _panoPriorityCount = PANO_IMPORT_PRIORITY_DEFAULT;
202
+ /** 当前优先加载个数(只读,回显用)。 */
203
+ get panoPriorityCount() {
204
+ return this._panoPriorityCount;
205
+ }
196
206
  // 复用临时对象(避免每帧 new,降低 GC 压力)
197
207
  _tmpV3 = new Vector3();
198
208
  /**
@@ -284,6 +294,7 @@ export default class MktMesh extends Mesh {
284
294
  // `updateTexture`(它里面那记 `pump` 是「第一次拿到 renderer ⇒ 在册组整份作废」的现场)之后、
285
295
  // `feedFaceMapping`(名册逐槽入册)之前 —— 顺序倒过来就是「名册在前、我们垫底」= 静默失效。
286
296
  // 编辑态那份在 `MktDevMesh` 的宿主循环里照同一相对位置点(⛔ 别在这儿加第三处)。
297
+ // 2026-11-05 需求:这一批点几颗改由 `panoPriorityCount` 定(默认 4,-1 = 存档全部)⇒ 本句不动。
287
298
  this.prefetchHeadPanoramas();
288
299
  this.feedFaceMapping();
289
300
  this.tickProgressiveReveal();
@@ -1069,6 +1080,18 @@ export default class MktMesh extends Mesh {
1069
1080
  return;
1070
1081
  this._panoTexMode = next;
1071
1082
  }
1083
+ /** 设置优先加载的全景个数:-1 = 全部 / 0 = 不提前 / n = 存档前 n 个(2026-11-05 需求)。
1084
+ * 收口:负数(含 -2、NaN 一类)一律归到 -1「全加载」,正数向下取整;非有限值回落默认档。
1085
+ * 改档⛔ 需要重新 `importData` —— 名册每帧重排,下一帧 `prefetchHeadPanoramas` 直接用新数点名
1086
+ * (`prioritize` 幂等 ⇒ 已在队列里的那批不重复投喂,也不会把已出队的旧批再塞回去)。 */
1087
+ setPanoPriorityCount(count) {
1088
+ if (!Number.isFinite(count))
1089
+ this._panoPriorityCount = PANO_IMPORT_PRIORITY_DEFAULT;
1090
+ else {
1091
+ const n = Math.floor(count);
1092
+ this._panoPriorityCount = n < 0 ? -1 : n;
1093
+ }
1094
+ }
1072
1095
  /**
1073
1096
  * 记下「当前激活(名册 0 号槽 = 中心)是第几个全景」。
1074
1097
  * ⭐ 皇上旨 2026-10-03 旨②:预览立方体已与墙**共用同一份材质**,原来那句「把中心那几张抄给立方体单全景材质」
@@ -1189,6 +1212,9 @@ export default class MktMesh extends Mesh {
1189
1212
  ms.dispose();
1190
1213
  }
1191
1214
  this.models = [];
1215
+ // 用户 2026-11-04「日志少打印点吧」⇒ 这一路(进厅 / 切项目 / 预览的整层重建)几十颗⛔ 不再一颗一行:
1216
+ // 名单先攒着,循环结束打**一条**(文件名口径与 `setModelUrl` 那一记共用 `glbFileName`)⇒ 一颗不落、一行到位。
1217
+ const glbNames = [];
1192
1218
  for (const d of datas) {
1193
1219
  const ms = new ModelSlot(d.name);
1194
1220
  ms.applyData(d);
@@ -1200,9 +1226,12 @@ export default class MktMesh extends Mesh {
1200
1226
  //(⚠️ 与旧的「GLB 在路上时这一槽露出占位方块」不同:整层重建这一路一颗都不露,直到轮到自己亮)
1201
1227
  ms.visible = false;
1202
1228
  this._revealPending.push(ms);
1229
+ glbNames.push(glbFileName(d.url));
1203
1230
  void ms.loadModel(d.url).then(() => this._queueModelMount(ms, onEachLoad));
1204
1231
  }
1205
1232
  }
1233
+ if (glbNames.length > 0)
1234
+ console.log(`[MktMesh] 整层重建加载 ${glbNames.length} 个 glb:${glbNames.join("、")}`);
1206
1235
  }
1207
1236
  /** 把「备好待挂」的那一槽交给全局队列;⛔ 去重闸门自备(`addWork` 按闭包引用判重,新闭包它判不出来) */
1208
1237
  _queueModelMount(ms, onEachLoad) {
@@ -1641,11 +1670,12 @@ export default class MktMesh extends Mesh {
1641
1670
  this.setModels((data.models ?? []).map((d) => ({ ...d, url: d.url ? absAssetUrl(d.url, pn) : "" })), onModelLoad);
1642
1671
  }
1643
1672
  /**
1644
- * 把**存档里前 `PANO_IMPORT_PRIORITY` 个全景**按存档顺序登记进拼合器的**加载任务队列**
1673
+ * 把**存档里前 `panoPriorityCount` 个全景**(-1 = 整份全加载)按存档顺序登记进拼合器的**加载任务队列**
1645
1674
  *(2026-10-29 需求「优先加载前 2 个全景素材」+ 2026-10-30 需求逐字:「给 mktmesh 内部加一个加载任务队列,
1646
1675
  * 把模型和平面图排在最前面,然后按照顺序,依次把前 2 个全景的顺序把瓦片加载任务添加到任务队列里面,
1647
1676
  * 第一个加载完了,就把这个加载任务从任务队列里面去掉,然后加载第二个;
1648
- * 如果后续再按照距离计算需要加载哪个全景的时候,同样地添加到加载任务队列的后面等待加载」)。
1677
+ * 如果后续再按照距离计算需要加载哪个全景的时候,同样地添加到加载任务队列的后面等待加载」
1678
+ * + 2026-11-05 需求「提前加载多少个全景改由配置决定,-1 全加载 / 0 不加载 / n 前 n 个,默认 4」)。
1649
1679
  * 由两处渲染循环每帧点一次:基类 `onBeforeRender`(预览弹窗 / 发布页)与编辑器的 `updateFloorTexture`
1650
1680
  *(`MktDevMesh` 覆写了 `onBeforeRender`,只点 `syncProjInvMatrix` + `tickProgressiveReveal`,库内那句在编辑态不跑)。
1651
1681
  *
@@ -1654,27 +1684,31 @@ export default class MktMesh extends Mesh {
1654
1684
  * (GLB 逐只 `loadModel` 不 throttle;平面那 4 片一次性 `_imgTileLoader.preload`),
1655
1685
  * 而全景瓦片最早也要等下一帧的 `pump` 才发第一片 ⇒ 发起次序天然是「模型 → 平面图 → 全景」,
1656
1686
  * ⛔ 这里不再把它们塞进队列重复管一遍(塞了就是第二把尺)。
1657
- * ② 本方法把存档 0 号、1 号依次 `prioritize` 进队列 ⇒ 拼合器每帧**只喂队首那一组**,
1687
+ * ② 本方法把存档 0 号起这 n 号依次 `prioritize` 进队列 ⇒ 拼合器每帧**只喂队首那一组**,
1658
1688
  * 0 号 32 格全落定(画进 RT,或该格本就缺地址/判死)才出队、1 号才开始发第一片。
1659
1689
  * 比 10-29 那版(只是把两批挪到在册顺序前面、额度仍被两批分摊)更硬:**第 1 颗独享额度,最快出全图**。
1690
+ * n = -1(全加载)时这一条变成「整份存档按存档顺序串行」⇒ 距离名册插不到前面,出清速度取决于总片数。
1660
1691
  * ③ 名册按距离点到的其他全景走 `feedFaceMapping` 里那句 `ensure(t.tiles)`(不带队列)⇒ 只入册、
1661
1692
  * 排在队列后面等,队列空了才按在册顺序补料(现行口径不变)。
1662
1693
  * ⚠️⚠️ 为什么每帧点而⛔ 只在 `importData` 里点一记:预览每开一次都换渲染器,`_switchRenderer` 把在册组
1663
1694
  * 整份作废(队列里的键也在同一刻失效);只在载入那次点名等于白点。本方法点在 `pump` 之后、名册逐槽
1664
- * `ensure` 之前 ⇒ 每帧把这两记任务补回队列头部(`prioritize` 幂等,已在队列里就不挪,所以 0 号恒在 1 号前)。
1695
+ * `ensure` 之前 ⇒ 每帧把这几记任务补回队列头部(`prioritize` 幂等,已在队列里就不挪,所以存档次序恒不变)。
1665
1696
  * ⚠️ 代价如实记:
1666
- * · 队列非空时**其他全景一片都不发** ⇒ 开机那几秒里,离注视点最近、但不在存档前两颗的那面墙会多灰一会儿。
1697
+ * · 队列非空时**其他全景一片都不发** ⇒ 开机那几秒里,离注视点最近、但不在存档前 n 颗的那面墙会多灰一会儿。
1667
1698
  * · 一颗「永远下不完」的瓦片(下载失败还没累计到判死)会压住队列;判死阈值是解码失败 3 次
1668
- * (`_dead`),退避重试期间队首不落定 ⇒ 第 2 颗跟着延后。真实数据里缺片是「地址为空/404」,
1699
+ * (`_dead`),退避重试期间队首不落定 ⇒ 后面几颗跟着延后。真实数据里缺片是「地址为空/404」,
1669
1700
  * 404 走三次即判死、那一格拼合涂灰 ⇒ 队列照样出清。
1670
- * · 这两颗全景若本来不在开机那一刻的名册里,它们的瓦片与拼合槽 RT 会被**提前**建出来
1671
- * (现行口径每颗全景 2 槽 × 6144² ≈ 384MB 显存)—— 组在运行中不淘汰,走进这两个景点迟早要建。
1701
+ * · 这批全景若本来不在开机那一刻的名册里,它们的瓦片与拼合槽 RT 会被**提前**建出来
1702
+ * (现行口径每颗全景 2 槽 × 6144² ≈ 384MB 显存)—— 组在运行中不淘汰,走进这些景点迟早要建;
1703
+ * ⚠️ n = -1(全加载)时这一条按存档全景总数放大,显存与在途请求都跟着涨。
1672
1704
  * ⚠️ 形态判据读 `this._panoTexMode`(整图形态那一档一张全景只有一张整图,走 `_texCache` 那条路,
1673
1705
  * 走错档就是白下 32 片)⇒ 宿主若用自己那份形态变量(编辑器的 `store.panoTexMode`),
1674
1706
  * 要在调 `importData` 之前先 `setPanoTexMode` 推进来,编辑器那侧已按此接线。
1675
1707
  */
1676
1708
  prefetchHeadPanoramas() {
1677
- const n = Math.min(PANO_IMPORT_PRIORITY, this.textures.length);
1709
+ const total = this.textures.length;
1710
+ // -1 = 整份;0 = 一个都不提前(这一句直接空转,其余全景仍按距离名册排队 = 回到 10-29 之前的行为)
1711
+ const n = this._panoPriorityCount < 0 ? total : Math.min(this._panoPriorityCount, total);
1678
1712
  // 正着点:0 号先入队 ⇒ 队首是它,落定后才轮到 1 号(`prioritize` 对已在队列里的键不挪位置)
1679
1713
  for (let i = 0; i < n; i++) {
1680
1714
  const info = this.textures[i];
@@ -75,12 +75,14 @@ import { stripAssetHost, absAssetUrl } from "./assetUrl";
75
75
  // 原先各住在本站两个渲染器里一份 ⇒ 下游直接用包里的 `MktMesh` 时,存档里的 `images`/`models` 两键没人读。
76
76
  // 入库之后它们挂在 `MktMesh` **自己的子树**下,与墙同一棵变换 ⇒ 预览那三根 transform 滑杆搬得动整份展品。
77
77
  import { ImagePlane, type ImagePlaneData } from "./imagePlane";
78
- import { ModelSlot, type ModelSlotData } from "./modelSlot";
78
+ import { ModelSlot, glbFileName, type ModelSlotData } from "./modelSlot";
79
79
  import { ImagePlaneTileLoader } from "./imageTileLoader";
80
80
 
81
- /** 载入 JSON 时**优先**加载几个全景的素材(2026-10-29 需求「优先加载前 2 个全景图的素材,然后再加载其他的」)。
82
- * 口径 = 存档 `textures` 数组的前 n 项;其余全景照常按每帧的槽位名册(离注视点最近 8 个)排队,只是排在它们后面。 */
83
- const PANO_IMPORT_PRIORITY = 2;
81
+ /** 载入 JSON 时**优先**加载几个全景的素材的**默认档**(数值只写这一处)。
82
+ * 口径 = 存档 `textures` 数组的前 n 项(n = -1 视为整份全加载);其余全景照常按每帧的槽位名册(离注视点最近 8 个)排队,只是排在它们后面。
83
+ * 2026-10-29 需求「优先加载前 2 个全景图的素材,然后再加载其他的」立的是 2;
84
+ * 2026-11-05 需求逐字「现在要默认把全景全部都要默认加载, 现在需要通过配置来设置, 提前加载多少个全景 -1 就是全加载 0就是不加载 1就是加载个 2就提前加载2个 一次类推, 默认加载前4个」⇒ 换成 4,并让这一档可被实例覆写。 */
85
+ const PANO_IMPORT_PRIORITY_DEFAULT = 4;
84
86
 
85
87
  /** 隐藏期预热那一次绘制用的镜头(模块级共用:它只是个「有合法的投影矩阵可读」的载体,永不动 ⇒ ⛔ 不必每实例造一份)。 */
86
88
  const GPU_WARM_CAMERA = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
@@ -231,6 +233,15 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
231
233
  return this._panoTexMode;
232
234
  }
233
235
 
236
+ /** 优先加载的全景个数(存档 `textures` 的前 n 项):**-1 = 全部、0 = 一个都不提前、n = 前 n 个**(2026-11-05 需求)。
237
+ * 默认取 `PANO_IMPORT_PRIORITY_DEFAULT`(=4);宿主想换档在渲染循环跑起来之前 `setPanoPriorityCount(-1)` 一行即可
238
+ * (与 `_ossSize`、`_panoTexMode` 同一套注入路径 ⇒ 编辑器与预览各自的配置各自推下来)。 */
239
+ private _panoPriorityCount: number = PANO_IMPORT_PRIORITY_DEFAULT;
240
+ /** 当前优先加载个数(只读,回显用)。 */
241
+ get panoPriorityCount(): number {
242
+ return this._panoPriorityCount;
243
+ }
244
+
234
245
  // 复用临时对象(避免每帧 new,降低 GC 压力)
235
246
  private _tmpV3: Vector3 = new Vector3();
236
247
 
@@ -323,6 +334,7 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
323
334
  // `updateTexture`(它里面那记 `pump` 是「第一次拿到 renderer ⇒ 在册组整份作废」的现场)之后、
324
335
  // `feedFaceMapping`(名册逐槽入册)之前 —— 顺序倒过来就是「名册在前、我们垫底」= 静默失效。
325
336
  // 编辑态那份在 `MktDevMesh` 的宿主循环里照同一相对位置点(⛔ 别在这儿加第三处)。
337
+ // 2026-11-05 需求:这一批点几颗改由 `panoPriorityCount` 定(默认 4,-1 = 存档全部)⇒ 本句不动。
326
338
  this.prefetchHeadPanoramas();
327
339
  this.feedFaceMapping();
328
340
  this.tickProgressiveReveal();
@@ -1108,6 +1120,18 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
1108
1120
  this._panoTexMode = next;
1109
1121
  }
1110
1122
 
1123
+ /** 设置优先加载的全景个数:-1 = 全部 / 0 = 不提前 / n = 存档前 n 个(2026-11-05 需求)。
1124
+ * 收口:负数(含 -2、NaN 一类)一律归到 -1「全加载」,正数向下取整;非有限值回落默认档。
1125
+ * 改档⛔ 需要重新 `importData` —— 名册每帧重排,下一帧 `prefetchHeadPanoramas` 直接用新数点名
1126
+ * (`prioritize` 幂等 ⇒ 已在队列里的那批不重复投喂,也不会把已出队的旧批再塞回去)。 */
1127
+ setPanoPriorityCount(count: number): void {
1128
+ if (!Number.isFinite(count)) this._panoPriorityCount = PANO_IMPORT_PRIORITY_DEFAULT;
1129
+ else {
1130
+ const n = Math.floor(count);
1131
+ this._panoPriorityCount = n < 0 ? -1 : n;
1132
+ }
1133
+ }
1134
+
1111
1135
  /**
1112
1136
  * 记下「当前激活(名册 0 号槽 = 中心)是第几个全景」。
1113
1137
  * ⭐ 皇上旨 2026-10-03 旨②:预览立方体已与墙**共用同一份材质**,原来那句「把中心那几张抄给立方体单全景材质」
@@ -1237,6 +1261,9 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
1237
1261
  ms.dispose();
1238
1262
  }
1239
1263
  this.models = [];
1264
+ // 用户 2026-11-04「日志少打印点吧」⇒ 这一路(进厅 / 切项目 / 预览的整层重建)几十颗⛔ 不再一颗一行:
1265
+ // 名单先攒着,循环结束打**一条**(文件名口径与 `setModelUrl` 那一记共用 `glbFileName`)⇒ 一颗不落、一行到位。
1266
+ const glbNames: string[] = [];
1240
1267
  for (const d of datas) {
1241
1268
  const ms = new ModelSlot(d.name);
1242
1269
  ms.applyData(d);
@@ -1248,9 +1275,11 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
1248
1275
  //(⚠️ 与旧的「GLB 在路上时这一槽露出占位方块」不同:整层重建这一路一颗都不露,直到轮到自己亮)
1249
1276
  ms.visible = false;
1250
1277
  this._revealPending.push(ms);
1278
+ glbNames.push(glbFileName(d.url));
1251
1279
  void ms.loadModel(d.url).then(() => this._queueModelMount(ms, onEachLoad));
1252
1280
  }
1253
1281
  }
1282
+ if (glbNames.length > 0) console.log(`[MktMesh] 整层重建加载 ${glbNames.length} 个 glb:${glbNames.join("、")}`);
1254
1283
  }
1255
1284
 
1256
1285
  /** 把「备好待挂」的那一槽交给全局队列;⛔ 去重闸门自备(`addWork` 按闭包引用判重,新闭包它判不出来) */
@@ -1683,11 +1712,12 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
1683
1712
  }
1684
1713
 
1685
1714
  /**
1686
- * 把**存档里前 `PANO_IMPORT_PRIORITY` 个全景**按存档顺序登记进拼合器的**加载任务队列**
1715
+ * 把**存档里前 `panoPriorityCount` 个全景**(-1 = 整份全加载)按存档顺序登记进拼合器的**加载任务队列**
1687
1716
  *(2026-10-29 需求「优先加载前 2 个全景素材」+ 2026-10-30 需求逐字:「给 mktmesh 内部加一个加载任务队列,
1688
1717
  * 把模型和平面图排在最前面,然后按照顺序,依次把前 2 个全景的顺序把瓦片加载任务添加到任务队列里面,
1689
1718
  * 第一个加载完了,就把这个加载任务从任务队列里面去掉,然后加载第二个;
1690
- * 如果后续再按照距离计算需要加载哪个全景的时候,同样地添加到加载任务队列的后面等待加载」)。
1719
+ * 如果后续再按照距离计算需要加载哪个全景的时候,同样地添加到加载任务队列的后面等待加载」
1720
+ * + 2026-11-05 需求「提前加载多少个全景改由配置决定,-1 全加载 / 0 不加载 / n 前 n 个,默认 4」)。
1691
1721
  * 由两处渲染循环每帧点一次:基类 `onBeforeRender`(预览弹窗 / 发布页)与编辑器的 `updateFloorTexture`
1692
1722
  *(`MktDevMesh` 覆写了 `onBeforeRender`,只点 `syncProjInvMatrix` + `tickProgressiveReveal`,库内那句在编辑态不跑)。
1693
1723
  *
@@ -1696,27 +1726,31 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
1696
1726
  * (GLB 逐只 `loadModel` 不 throttle;平面那 4 片一次性 `_imgTileLoader.preload`),
1697
1727
  * 而全景瓦片最早也要等下一帧的 `pump` 才发第一片 ⇒ 发起次序天然是「模型 → 平面图 → 全景」,
1698
1728
  * ⛔ 这里不再把它们塞进队列重复管一遍(塞了就是第二把尺)。
1699
- * ② 本方法把存档 0 号、1 号依次 `prioritize` 进队列 ⇒ 拼合器每帧**只喂队首那一组**,
1729
+ * ② 本方法把存档 0 号起这 n 号依次 `prioritize` 进队列 ⇒ 拼合器每帧**只喂队首那一组**,
1700
1730
  * 0 号 32 格全落定(画进 RT,或该格本就缺地址/判死)才出队、1 号才开始发第一片。
1701
1731
  * 比 10-29 那版(只是把两批挪到在册顺序前面、额度仍被两批分摊)更硬:**第 1 颗独享额度,最快出全图**。
1732
+ * n = -1(全加载)时这一条变成「整份存档按存档顺序串行」⇒ 距离名册插不到前面,出清速度取决于总片数。
1702
1733
  * ③ 名册按距离点到的其他全景走 `feedFaceMapping` 里那句 `ensure(t.tiles)`(不带队列)⇒ 只入册、
1703
1734
  * 排在队列后面等,队列空了才按在册顺序补料(现行口径不变)。
1704
1735
  * ⚠️⚠️ 为什么每帧点而⛔ 只在 `importData` 里点一记:预览每开一次都换渲染器,`_switchRenderer` 把在册组
1705
1736
  * 整份作废(队列里的键也在同一刻失效);只在载入那次点名等于白点。本方法点在 `pump` 之后、名册逐槽
1706
- * `ensure` 之前 ⇒ 每帧把这两记任务补回队列头部(`prioritize` 幂等,已在队列里就不挪,所以 0 号恒在 1 号前)。
1737
+ * `ensure` 之前 ⇒ 每帧把这几记任务补回队列头部(`prioritize` 幂等,已在队列里就不挪,所以存档次序恒不变)。
1707
1738
  * ⚠️ 代价如实记:
1708
- * · 队列非空时**其他全景一片都不发** ⇒ 开机那几秒里,离注视点最近、但不在存档前两颗的那面墙会多灰一会儿。
1739
+ * · 队列非空时**其他全景一片都不发** ⇒ 开机那几秒里,离注视点最近、但不在存档前 n 颗的那面墙会多灰一会儿。
1709
1740
  * · 一颗「永远下不完」的瓦片(下载失败还没累计到判死)会压住队列;判死阈值是解码失败 3 次
1710
- * (`_dead`),退避重试期间队首不落定 ⇒ 第 2 颗跟着延后。真实数据里缺片是「地址为空/404」,
1741
+ * (`_dead`),退避重试期间队首不落定 ⇒ 后面几颗跟着延后。真实数据里缺片是「地址为空/404」,
1711
1742
  * 404 走三次即判死、那一格拼合涂灰 ⇒ 队列照样出清。
1712
- * · 这两颗全景若本来不在开机那一刻的名册里,它们的瓦片与拼合槽 RT 会被**提前**建出来
1713
- * (现行口径每颗全景 2 槽 × 6144² ≈ 384MB 显存)—— 组在运行中不淘汰,走进这两个景点迟早要建。
1743
+ * · 这批全景若本来不在开机那一刻的名册里,它们的瓦片与拼合槽 RT 会被**提前**建出来
1744
+ * (现行口径每颗全景 2 槽 × 6144² ≈ 384MB 显存)—— 组在运行中不淘汰,走进这些景点迟早要建;
1745
+ * ⚠️ n = -1(全加载)时这一条按存档全景总数放大,显存与在途请求都跟着涨。
1714
1746
  * ⚠️ 形态判据读 `this._panoTexMode`(整图形态那一档一张全景只有一张整图,走 `_texCache` 那条路,
1715
1747
  * 走错档就是白下 32 片)⇒ 宿主若用自己那份形态变量(编辑器的 `store.panoTexMode`),
1716
1748
  * 要在调 `importData` 之前先 `setPanoTexMode` 推进来,编辑器那侧已按此接线。
1717
1749
  */
1718
1750
  prefetchHeadPanoramas(): void {
1719
- const n = Math.min(PANO_IMPORT_PRIORITY, this.textures.length);
1751
+ const total = this.textures.length;
1752
+ // -1 = 整份;0 = 一个都不提前(这一句直接空转,其余全景仍按距离名册排队 = 回到 10-29 之前的行为)
1753
+ const n = this._panoPriorityCount < 0 ? total : Math.min(this._panoPriorityCount, total);
1720
1754
  // 正着点:0 号先入队 ⇒ 队首是它,落定后才轮到 1 号(`prioritize` 对已在队列里的键不挪位置)
1721
1755
  for (let i = 0; i < n; i++) {
1722
1756
  const info = this.textures[i];
@@ -39,6 +39,10 @@ export declare class ImagePlane extends Mesh<PlaneGeometry, MeshBasicMaterial> {
39
39
  private _data;
40
40
  /** 内容是否已落定:4 格里至少有一格真的绑上了纹理。**只朝 true 翻一次**(之后逐张显影继续,但不再回退成「没内容」)。 */
41
41
  private _loaded;
42
+ /** 此刻是不是编辑器的选中态(决定上不上黄、边框亮不亮) */
43
+ private _selected;
44
+ /** 选中边框:懒建(预览/发布那一路从不选中 ⇒ 一颗都不建) */
45
+ private _outline;
42
46
  constructor(name?: string);
43
47
  get imageWidth(): number;
44
48
  get imageHeight(): number;
@@ -57,6 +61,16 @@ export declare class ImagePlane extends Mesh<PlaneGeometry, MeshBasicMaterial> {
57
61
  tilesBindingStamp(): string;
58
62
  highlight(): void;
59
63
  restoreColor(): void;
64
+ /**
65
+ * 选中态的三样东西一起算(颜色 + 边框),一处规则:
66
+ * - 黄只在**还没上传图**那一档:片元里贴图是乘进 diffuse 的(`diffuseColor *= imgTexel`),有图还套黄 ⇒ 整张图发黄。
67
+ * - 边框在有图/没图两档都亮:没图那四格是整格 `discard`(一个像素都不落),只靠黄等于没有反馈,看不见也找不着。
68
+ */
69
+ private _syncSelection;
70
+ /** 选中边框懒建:预览/发布那一路从不选中 ⇒ 一颗 LineSegments2 都不建(与模型槽那圈 `BoxLine2` 同口径) */
71
+ private _ensureOutline;
72
+ /** 四条边按当前几何的宽高铺在平面那一平面上(局部 z=0,中心为原点) */
73
+ private _layoutOutline;
60
74
  toData(): ImagePlaneData;
61
75
  applyData(d: ImagePlaneData): void;
62
76
  dispose(): void;
@@ -16,7 +16,10 @@
16
16
  * 而是直接 discrad 丢弃」⇒ 默认图与缺图灰两条都已删,没就绪就是透明没有。)
17
17
  * - UV 标准 [0,1]²(PlaneGeometry 自带),4 张瓦片按 2×2 象限铺满 ⇒ 不需要投影 shader,也不需要离屏 RT。
18
18
  */
19
- import { Color, DataTexture, FrontSide, Mesh, MeshBasicMaterial, PlaneGeometry, RGBAFormat, Vector4, } from "three";
19
+ import { Color, DataTexture, FrontSide, Mesh, MeshBasicMaterial, PlaneGeometry, RGBAFormat, Vector2, Vector4, } from "three";
20
+ import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
21
+ import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
22
+ import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";
20
23
  /*
21
24
  * ⭐ 2026-10-22 晚旨:图片平面**不用拼合器 RT**,直接把上传切好的 4 张瓦片纹理绑到材质,
22
25
  * 片元按 UV 象限选片 + 用每片 `rtStitchTileInset` 窗口采样(与上传切片共用同一份几何 ⇒ 接缝不回潮)。
@@ -61,6 +64,9 @@ const IMG_PLANE_MAP_FRAG = `
61
64
  const DEFAULT_WIDTH = 2;
62
65
  const DEFAULT_HEIGHT = 1.5;
63
66
  const HIGHLIGHT_COLOR = "#ffd24a";
67
+ /** 选中边框:颜色/粗细与模型槽那圈 `BoxLine2` 的朝向相机的棱同一档(0x00ff00 / 2 屏幕像素) */
68
+ const SELECT_LINE_COLOR = 0x00ff00;
69
+ const SELECT_LINE_WIDTH_PX = 2;
64
70
  export class ImagePlane extends Mesh {
65
71
  _originalColor = "#ffffff";
66
72
  _uTiles;
@@ -72,6 +78,10 @@ export class ImagePlane extends Mesh {
72
78
  };
73
79
  /** 内容是否已落定:4 格里至少有一格真的绑上了纹理。**只朝 true 翻一次**(之后逐张显影继续,但不再回退成「没内容」)。 */
74
80
  _loaded = false;
81
+ /** 此刻是不是编辑器的选中态(决定上不上黄、边框亮不亮) */
82
+ _selected = false;
83
+ /** 选中边框:懒建(预览/发布那一路从不选中 ⇒ 一颗都不建) */
84
+ _outline = null;
75
85
  constructor(name) {
76
86
  const geo = new PlaneGeometry(DEFAULT_WIDTH, DEFAULT_HEIGHT);
77
87
  const mat = new MeshBasicMaterial({
@@ -115,6 +125,7 @@ export class ImagePlane extends Mesh {
115
125
  setPlaneSize(w, h) {
116
126
  this.geometry.dispose();
117
127
  this.geometry = new PlaneGeometry(w, h);
128
+ this._layoutOutline(); // 边框跟着宽高走,否则面板改完尺寸框还停在旧那一圈
118
129
  }
119
130
  setPlaneData(d) {
120
131
  this._data = { url: d.url, tiles: d.tiles, naturalW: d.naturalW, naturalH: d.naturalH };
@@ -125,6 +136,7 @@ export class ImagePlane extends Mesh {
125
136
  get naturalH() { return this._data.naturalH; }
126
137
  /** 把上传切好的 4 张瓦片纹理 + 每片 inset 绑到材质(缺图那片传 null ⇒ 那一片整格 discard,不落像素)。uTiles 用持久对象引用 ⇒ 改 value 实时生效 */
127
138
  setTileTextures(texs, insets) {
139
+ const hadContent = this._loaded;
128
140
  for (let i = 0; i < 4; i++) {
129
141
  const t = texs[i] || null;
130
142
  this._uTiles.value[i] = t || PLACEHOLDER_TEX;
@@ -136,6 +148,10 @@ export class ImagePlane extends Mesh {
136
148
  if (t)
137
149
  this._loaded = true;
138
150
  }
151
+ // 上传前这颗是选中态(黄),第一张图一到就把黄摘掉 ⇒ 否则「有图不上色」只在再次选中时才生效,
152
+ // 而他报的正是「上传后」那一刻。
153
+ if (!hadContent && this._loaded)
154
+ this._syncSelection();
139
155
  }
140
156
  /** 只读「此刻材质里绑了什么」的戳记:4 格就绪位 + 已绑纹理的版本号。
141
157
  * 按需渲染的宿主拿它比对画面:逐帧绑定只写 uniform 值,⛔ 不动 `material.version`、也不动几何 ⇒ 场景指纹看不见这一变。 */
@@ -148,10 +164,66 @@ export class ImagePlane extends Mesh {
148
164
  return s;
149
165
  }
150
166
  highlight() {
151
- this.material.color.set(HIGHLIGHT_COLOR);
167
+ this._selected = true;
168
+ this._syncSelection();
152
169
  }
153
170
  restoreColor() {
154
- this.material.color.set(this._originalColor);
171
+ this._selected = false;
172
+ this._syncSelection();
173
+ }
174
+ /**
175
+ * 选中态的三样东西一起算(颜色 + 边框),一处规则:
176
+ * - 黄只在**还没上传图**那一档:片元里贴图是乘进 diffuse 的(`diffuseColor *= imgTexel`),有图还套黄 ⇒ 整张图发黄。
177
+ * - 边框在有图/没图两档都亮:没图那四格是整格 `discard`(一个像素都不落),只靠黄等于没有反馈,看不见也找不着。
178
+ */
179
+ _syncSelection() {
180
+ this.material.color.set(this._selected && !this._loaded ? HIGHLIGHT_COLOR : this._originalColor);
181
+ if (!this._selected) {
182
+ if (this._outline)
183
+ this._outline.visible = false;
184
+ return;
185
+ }
186
+ this._ensureOutline();
187
+ this._layoutOutline();
188
+ }
189
+ /** 选中边框懒建:预览/发布那一路从不选中 ⇒ 一颗 LineSegments2 都不建(与模型槽那圈 `BoxLine2` 同口径) */
190
+ _ensureOutline() {
191
+ if (this._outline)
192
+ return;
193
+ const material = new LineMaterial({
194
+ color: SELECT_LINE_COLOR,
195
+ worldUnits: false,
196
+ linewidth: SELECT_LINE_WIDTH_PX,
197
+ // ⚠️ `depthTest: false`:这一圈就躺在平面自己那一平面上,留着深度测试必与图打摆(闪烁)
198
+ depthTest: false,
199
+ resolution: new Vector2(1, 1),
200
+ });
201
+ const outline = new LineSegments2(new LineSegmentsGeometry(), material);
202
+ outline.name = `${this.name}-选中边框`;
203
+ outline.renderOrder = 100; // 与 `BoxLine2` 那圈同档:压在图/墙之后
204
+ outline.frustumCulled = false;
205
+ outline.userData.pickIgnored = true;
206
+ outline.raycast = () => { }; // 拾取打平面本体,边框不是可点的东西
207
+ outline.onBeforeRender = (renderer) => {
208
+ renderer.getSize(material.resolution); // 屏幕像素粗细要每帧拿画布尺寸
209
+ };
210
+ this.add(outline);
211
+ this._outline = outline;
212
+ }
213
+ /** 四条边按当前几何的宽高铺在平面那一平面上(局部 z=0,中心为原点) */
214
+ _layoutOutline() {
215
+ if (!this._outline)
216
+ return;
217
+ const w = this.imageWidth / 2;
218
+ const h = this.imageHeight / 2;
219
+ const c = [
220
+ [-w, -h, 0], [w, -h, 0],
221
+ [w, -h, 0], [w, h, 0],
222
+ [w, h, 0], [-w, h, 0],
223
+ [-w, h, 0], [-w, -h, 0],
224
+ ].flat();
225
+ this._outline.geometry.setPositions(c);
226
+ this._outline.visible = true;
155
227
  }
156
228
  toData() {
157
229
  return {
@@ -177,6 +249,10 @@ export class ImagePlane extends Mesh {
177
249
  dispose() {
178
250
  this.geometry.dispose();
179
251
  this.material.dispose();
252
+ if (this._outline) {
253
+ this._outline.geometry.dispose();
254
+ this._outline.material.dispose();
255
+ }
180
256
  }
181
257
  }
182
258
  export const IMAGE_PLANE_DEFAULTS = { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT };
@@ -25,8 +25,13 @@ import {
25
25
  PlaneGeometry,
26
26
  RGBAFormat,
27
27
  Texture,
28
+ Vector2,
28
29
  Vector4,
30
+ WebGLRenderer,
29
31
  } from "three";
32
+ import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
33
+ import { LineSegments2 } from "three/examples/jsm/lines/LineSegments2.js";
34
+ import { LineSegmentsGeometry } from "three/examples/jsm/lines/LineSegmentsGeometry.js";
30
35
 
31
36
  /*
32
37
  * ⭐ 2026-10-22 晚旨:图片平面**不用拼合器 RT**,直接把上传切好的 4 张瓦片纹理绑到材质,
@@ -92,6 +97,9 @@ export interface ImagePlaneData {
92
97
  const DEFAULT_WIDTH = 2;
93
98
  const DEFAULT_HEIGHT = 1.5;
94
99
  const HIGHLIGHT_COLOR = "#ffd24a";
100
+ /** 选中边框:颜色/粗细与模型槽那圈 `BoxLine2` 的朝向相机的棱同一档(0x00ff00 / 2 屏幕像素) */
101
+ const SELECT_LINE_COLOR = 0x00ff00;
102
+ const SELECT_LINE_WIDTH_PX = 2;
95
103
 
96
104
  export class ImagePlane extends Mesh<PlaneGeometry, MeshBasicMaterial> {
97
105
  private _originalColor: string = "#ffffff";
@@ -104,6 +112,10 @@ export class ImagePlane extends Mesh<PlaneGeometry, MeshBasicMaterial> {
104
112
  };
105
113
  /** 内容是否已落定:4 格里至少有一格真的绑上了纹理。**只朝 true 翻一次**(之后逐张显影继续,但不再回退成「没内容」)。 */
106
114
  private _loaded = false;
115
+ /** 此刻是不是编辑器的选中态(决定上不上黄、边框亮不亮) */
116
+ private _selected = false;
117
+ /** 选中边框:懒建(预览/发布那一路从不选中 ⇒ 一颗都不建) */
118
+ private _outline: LineSegments2 | null = null;
107
119
 
108
120
  constructor(name?: string) {
109
121
  const geo = new PlaneGeometry(DEFAULT_WIDTH, DEFAULT_HEIGHT);
@@ -153,6 +165,7 @@ export class ImagePlane extends Mesh<PlaneGeometry, MeshBasicMaterial> {
153
165
  setPlaneSize(w: number, h: number): void {
154
166
  this.geometry.dispose();
155
167
  this.geometry = new PlaneGeometry(w, h);
168
+ this._layoutOutline(); // 边框跟着宽高走,否则面板改完尺寸框还停在旧那一圈
156
169
  }
157
170
 
158
171
  setPlaneData(d: Pick<ImagePlaneData, "url" | "tiles" | "naturalW" | "naturalH">): void {
@@ -166,6 +179,7 @@ export class ImagePlane extends Mesh<PlaneGeometry, MeshBasicMaterial> {
166
179
 
167
180
  /** 把上传切好的 4 张瓦片纹理 + 每片 inset 绑到材质(缺图那片传 null ⇒ 那一片整格 discard,不落像素)。uTiles 用持久对象引用 ⇒ 改 value 实时生效 */
168
181
  setTileTextures(texs: (Texture | null)[], insets: Vector4[]): void {
182
+ const hadContent = this._loaded;
169
183
  for (let i = 0; i < 4; i++) {
170
184
  const t = texs[i] || null;
171
185
  this._uTiles.value[i] = t || PLACEHOLDER_TEX;
@@ -175,6 +189,9 @@ export class ImagePlane extends Mesh<PlaneGeometry, MeshBasicMaterial> {
175
189
  // 「逐张就绪逐张显影」是本文件 10-22 晚定下的口径,等到全到齐会让先到的那几格白等几帧)。
176
190
  if (t) this._loaded = true;
177
191
  }
192
+ // 上传前这颗是选中态(黄),第一张图一到就把黄摘掉 ⇒ 否则「有图不上色」只在再次选中时才生效,
193
+ // 而他报的正是「上传后」那一刻。
194
+ if (!hadContent && this._loaded) this._syncSelection();
178
195
  }
179
196
 
180
197
  /** 只读「此刻材质里绑了什么」的戳记:4 格就绪位 + 已绑纹理的版本号。
@@ -189,11 +206,67 @@ export class ImagePlane extends Mesh<PlaneGeometry, MeshBasicMaterial> {
189
206
  }
190
207
 
191
208
  highlight(): void {
192
- this.material.color.set(HIGHLIGHT_COLOR);
209
+ this._selected = true;
210
+ this._syncSelection();
193
211
  }
194
212
 
195
213
  restoreColor(): void {
196
- this.material.color.set(this._originalColor);
214
+ this._selected = false;
215
+ this._syncSelection();
216
+ }
217
+
218
+ /**
219
+ * 选中态的三样东西一起算(颜色 + 边框),一处规则:
220
+ * - 黄只在**还没上传图**那一档:片元里贴图是乘进 diffuse 的(`diffuseColor *= imgTexel`),有图还套黄 ⇒ 整张图发黄。
221
+ * - 边框在有图/没图两档都亮:没图那四格是整格 `discard`(一个像素都不落),只靠黄等于没有反馈,看不见也找不着。
222
+ */
223
+ private _syncSelection(): void {
224
+ this.material.color.set(this._selected && !this._loaded ? HIGHLIGHT_COLOR : this._originalColor);
225
+ if (!this._selected) {
226
+ if (this._outline) this._outline.visible = false;
227
+ return;
228
+ }
229
+ this._ensureOutline();
230
+ this._layoutOutline();
231
+ }
232
+
233
+ /** 选中边框懒建:预览/发布那一路从不选中 ⇒ 一颗 LineSegments2 都不建(与模型槽那圈 `BoxLine2` 同口径) */
234
+ private _ensureOutline(): void {
235
+ if (this._outline) return;
236
+ const material = new LineMaterial({
237
+ color: SELECT_LINE_COLOR,
238
+ worldUnits: false,
239
+ linewidth: SELECT_LINE_WIDTH_PX,
240
+ // ⚠️ `depthTest: false`:这一圈就躺在平面自己那一平面上,留着深度测试必与图打摆(闪烁)
241
+ depthTest: false,
242
+ resolution: new Vector2(1, 1),
243
+ });
244
+ const outline = new LineSegments2(new LineSegmentsGeometry(), material);
245
+ outline.name = `${this.name}-选中边框`;
246
+ outline.renderOrder = 100; // 与 `BoxLine2` 那圈同档:压在图/墙之后
247
+ outline.frustumCulled = false;
248
+ outline.userData.pickIgnored = true;
249
+ outline.raycast = (): void => { }; // 拾取打平面本体,边框不是可点的东西
250
+ outline.onBeforeRender = (renderer: WebGLRenderer): void => {
251
+ renderer.getSize(material.resolution); // 屏幕像素粗细要每帧拿画布尺寸
252
+ };
253
+ this.add(outline);
254
+ this._outline = outline;
255
+ }
256
+
257
+ /** 四条边按当前几何的宽高铺在平面那一平面上(局部 z=0,中心为原点) */
258
+ private _layoutOutline(): void {
259
+ if (!this._outline) return;
260
+ const w = this.imageWidth / 2;
261
+ const h = this.imageHeight / 2;
262
+ const c = [
263
+ [-w, -h, 0], [w, -h, 0],
264
+ [w, -h, 0], [w, h, 0],
265
+ [w, h, 0], [-w, h, 0],
266
+ [-w, h, 0], [-w, -h, 0],
267
+ ].flat();
268
+ this._outline.geometry.setPositions(c);
269
+ this._outline.visible = true;
197
270
  }
198
271
 
199
272
  toData(): ImagePlaneData {
@@ -221,6 +294,10 @@ export class ImagePlane extends Mesh<PlaneGeometry, MeshBasicMaterial> {
221
294
  dispose(): void {
222
295
  this.geometry.dispose();
223
296
  this.material.dispose();
297
+ if (this._outline) {
298
+ this._outline.geometry.dispose();
299
+ this._outline.material.dispose();
300
+ }
224
301
  }
225
302
  }
226
303
 
@@ -1,4 +1,6 @@
1
1
  import { Group } from "three";
2
+ /** glb 地址 → 文件名(日志唯一口径:去掉查询串取最后一段;⛔ 别在打印点各写一份 split) */
3
+ export declare function glbFileName(url: string): string;
2
4
  /** 模型槽的持久化数据(进 exportData / importData / 预览 loadData) */
3
5
  export interface ModelSlotData {
4
6
  name?: string;
@@ -32,6 +32,10 @@ import { Box3, BoxGeometry, Color, Group, Mesh, MeshBasicMaterial, Vector3, } fr
32
32
  import threeLoader from "../threeLoader";
33
33
  import threeUtils from "../threeUtils";
34
34
  import BoxLine2 from "./boxLine2";
35
+ /** glb 地址 → 文件名(日志唯一口径:去掉查询串取最后一段;⛔ 别在打印点各写一份 split) */
36
+ export function glbFileName(url) {
37
+ return url.split("?")[0].split("/").pop() || url;
38
+ }
35
39
  /** 占位小立方体的棱长(米)—— 皇上要的是「小立方体」,比「加立方体」那颗 1m 的单位方块小一档 */
36
40
  export const MODEL_SLOT_PLACEHOLDER = 0.3;
37
41
  /** 默认尺寸档(米,最长边):还没上传模型时占位方块用不上它;上传成功后 `useNativeSize()` 会把它填成模型原最长边 ⇒ 一比一 */
@@ -179,6 +183,9 @@ export class ModelSlot extends Group {
179
183
  this._loaded = true;
180
184
  return true;
181
185
  }
186
+ // 用户 2026-11-04「日志少打印点吧」⇒ 这一颗的取回入口⛔ 不出声:整层重建一次几十颗,逐颗一行会刷屏。
187
+ // 文件名改由两个批次入口各打一条 —— 单颗动作(上传/复制/换地址)在下面的 `setModelUrl`,
188
+ // 整层重建在 `MktMesh.setModels`(那边一条列全)⇒ 一颗不落,但一拍一条。
182
189
  // 取回失败(404 / 解析报错都从这一档走):`threeLoader.loadModel` 是会 reject 的,⚠️ 必须当「落定在占位方块」处理
183
190
  // —— 整层重建时这一颗是压着不亮的,不落定就永远隐形(旧写法里它至少还露得出占位方块)。
184
191
  let obj;
@@ -224,6 +231,11 @@ export class ModelSlot extends Group {
224
231
  * @returns 是否真的挂上了(地址无效/载入失败/这次结果已作废 ⇒ false;空地址 = 退回占位,返回 true)
225
232
  */
226
233
  async setModelUrl(url) {
234
+ // 用户 2026-11-04「日志少打印点吧」⇒ 逐颗一行从 `loadModel` 挪到这里:这一路一次动作只动一/几颗
235
+ // (上传 .glb、复制模型、换同名 glb),一条就是他能对上号的那一颗;整层重建那几十颗由
236
+ // `MktMesh.setModels` 攒成一条 ⇒ 文件名仍「一颗不落」,行数从 N 降到 1。空地址 = 退回占位,不打。
237
+ if (url)
238
+ console.log(`[MktMesh] 加载 glb「${this.name}」:${glbFileName(url)}`);
227
239
  if (!(await this.loadModel(url)))
228
240
  return false;
229
241
  if (this._staged)
@@ -42,6 +42,11 @@ import threeLoader from "../threeLoader";
42
42
  import threeUtils from "../threeUtils";
43
43
  import BoxLine2 from "./boxLine2";
44
44
 
45
+ /** glb 地址 → 文件名(日志唯一口径:去掉查询串取最后一段;⛔ 别在打印点各写一份 split) */
46
+ export function glbFileName(url: string): string {
47
+ return url.split("?")[0].split("/").pop() || url;
48
+ }
49
+
45
50
  /** 模型槽的持久化数据(进 exportData / importData / 预览 loadData) */
46
51
  export interface ModelSlotData {
47
52
  name?: string;
@@ -219,6 +224,9 @@ export class ModelSlot extends Group {
219
224
  this._loaded = true;
220
225
  return true;
221
226
  }
227
+ // 用户 2026-11-04「日志少打印点吧」⇒ 这一颗的取回入口⛔ 不出声:整层重建一次几十颗,逐颗一行会刷屏。
228
+ // 文件名改由两个批次入口各打一条 —— 单颗动作(上传/复制/换地址)在下面的 `setModelUrl`,
229
+ // 整层重建在 `MktMesh.setModels`(那边一条列全)⇒ 一颗不落,但一拍一条。
222
230
  // 取回失败(404 / 解析报错都从这一档走):`threeLoader.loadModel` 是会 reject 的,⚠️ 必须当「落定在占位方块」处理
223
231
  // —— 整层重建时这一颗是压着不亮的,不落定就永远隐形(旧写法里它至少还露得出占位方块)。
224
232
  let obj: Object3D | undefined;
@@ -263,6 +271,10 @@ export class ModelSlot extends Group {
263
271
  * @returns 是否真的挂上了(地址无效/载入失败/这次结果已作废 ⇒ false;空地址 = 退回占位,返回 true)
264
272
  */
265
273
  async setModelUrl(url: string): Promise<boolean> {
274
+ // 用户 2026-11-04「日志少打印点吧」⇒ 逐颗一行从 `loadModel` 挪到这里:这一路一次动作只动一/几颗
275
+ // (上传 .glb、复制模型、换同名 glb),一条就是他能对上号的那一颗;整层重建那几十颗由
276
+ // `MktMesh.setModels` 攒成一条 ⇒ 文件名仍「一颗不落」,行数从 N 降到 1。空地址 = 退回占位,不打。
277
+ if (url) console.log(`[MktMesh] 加载 glb「${this.name}」:${glbFileName(url)}`);
266
278
  if (!(await this.loadModel(url))) return false;
267
279
  if (this._staged) this.mountStaged();
268
280
  return true;
package/ThreeBase.js CHANGED
@@ -971,10 +971,8 @@ export default class ThreeBase extends EventDispatcher {
971
971
  // compare → R=全局 base 深度,G=最近一次 glow/stroke 选区的 select 深度。两路任一全白即该侧写入失败。
972
972
  // judge → R=base 原始深度、G=select 原始深度、B=判定结果(1=判为被遮挡应 discard)。
973
973
  // strokeSelect → 【描边步骤 1】选区本体深度(selectDepthTexture),白色=空背景、灰=物体本体。
974
- // strokeSelectBlur → 【描边步骤 5.5】描边外扩后的选区深度(selectDepthBlurTextureTarget),
975
- // 物体轮廓比本体档更胖一圈即外扩生效。
976
974
  // 通用控制台入口(任意页面可用,优先级高于面板属性):
977
- // `window.__TB_DEBUG_DEPTH = "global" | "ao" | "compare" | "judge" | "strokeSelect" | "strokeSelectBlur"`,
975
+ // `window.__TB_DEBUG_DEPTH = "global" | "ao" | "compare" | "judge" | "strokeSelect"`,
978
976
  // 设回 "none" 恢复。
979
977
  const dbgWin = window.__TB_DEBUG_DEPTH;
980
978
  const dbgMode = window.__BASE_DEPTH_VIS
@@ -1011,13 +1009,6 @@ export default class ThreeBase extends EventDispatcher {
1011
1009
  debugDepthState.lastSelectDepth ?? this.baseDepthTexture;
1012
1010
  fsQuad.render(this.renderer, baseDepthVisShader);
1013
1011
  }
1014
- else if (dbgMode === "strokeSelectBlur") {
1015
- // 描边步骤 5.5:外扩后的选区深度(RGBA8 目标,对数深度存在 .r 通道)。
1016
- // 与本体档对比看:外扩生效时物体轮廓会「胖一圈」,被遮挡的外扩环则被判 discard 后不参与。
1017
- baseDepthVisShader.uniforms.tDepth.value =
1018
- debugDepthState.lastSelectBlurDepth ?? this.baseDepthTexture;
1019
- fsQuad.render(this.renderer, baseDepthVisShader);
1020
- }
1021
1012
  flushLensflareOverlays(this.renderer, camera);
1022
1013
  return;
1023
1014
  }
package/ThreeBase.ts CHANGED
@@ -1071,10 +1071,8 @@ export default class ThreeBase extends EventDispatcher<ThreeBaseEventTypeMap> {
1071
1071
  // compare → R=全局 base 深度,G=最近一次 glow/stroke 选区的 select 深度。两路任一全白即该侧写入失败。
1072
1072
  // judge → R=base 原始深度、G=select 原始深度、B=判定结果(1=判为被遮挡应 discard)。
1073
1073
  // strokeSelect → 【描边步骤 1】选区本体深度(selectDepthTexture),白色=空背景、灰=物体本体。
1074
- // strokeSelectBlur → 【描边步骤 5.5】描边外扩后的选区深度(selectDepthBlurTextureTarget),
1075
- // 物体轮廓比本体档更胖一圈即外扩生效。
1076
1074
  // 通用控制台入口(任意页面可用,优先级高于面板属性):
1077
- // `window.__TB_DEBUG_DEPTH = "global" | "ao" | "compare" | "judge" | "strokeSelect" | "strokeSelectBlur"`,
1075
+ // `window.__TB_DEBUG_DEPTH = "global" | "ao" | "compare" | "judge" | "strokeSelect"`,
1078
1076
  // 设回 "none" 恢复。
1079
1077
  const dbgWin = (window as unknown as { __TB_DEBUG_DEPTH?: DebugDepthMode }).__TB_DEBUG_DEPTH;
1080
1078
  const dbgMode = (window as unknown as { __BASE_DEPTH_VIS?: boolean }).__BASE_DEPTH_VIS
@@ -1106,12 +1104,6 @@ export default class ThreeBase extends EventDispatcher<ThreeBaseEventTypeMap> {
1106
1104
  baseDepthVisShader.uniforms.tDepth.value =
1107
1105
  debugDepthState.lastSelectDepth ?? this.baseDepthTexture;
1108
1106
  fsQuad.render(this.renderer, baseDepthVisShader);
1109
- } else if (dbgMode === "strokeSelectBlur") {
1110
- // 描边步骤 5.5:外扩后的选区深度(RGBA8 目标,对数深度存在 .r 通道)。
1111
- // 与本体档对比看:外扩生效时物体轮廓会「胖一圈」,被遮挡的外扩环则被判 discard 后不参与。
1112
- baseDepthVisShader.uniforms.tDepth.value =
1113
- debugDepthState.lastSelectBlurDepth ?? this.baseDepthTexture;
1114
- fsQuad.render(this.renderer, baseDepthVisShader);
1115
1107
  }
1116
1108
  flushLensflareOverlays(this.renderer, camera);
1117
1109
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "three-base",
3
- "version": "2.0.197",
3
+ "version": "2.0.198",
4
4
  "description": "A Three.js extension library providing common 3D components, animations, materials, and utilities",
5
5
  "author": "xiaosihan",
6
6
  "publishConfig": {
@@ -1,4 +1,4 @@
1
- import type { DepthTexture, Texture } from "three";
1
+ import type { DepthTexture } from "three";
2
2
  /**
3
3
  * 深度调试预览模式(面板按钮与控制台 `window.__TB_DEBUG_DEPTH` 共用同一枚举):
4
4
  * - "none" :正常渲染(默认)
@@ -7,22 +7,18 @@ import type { DepthTexture, Texture } from "three";
7
7
  * - "compare" :R=base 深度、G=选区 select 深度(对数反解后归一化)
8
8
  * - "judge" :R=base 原始深度、G=select 原始深度、B=判定结果(1=判为被遮挡应 discard)
9
9
  * - "strokeSelect" :【描边步骤 1】选区本体深度(selectDepthTexture,mask 那趟光栅化顺带写入)
10
- * - "strokeSelectBlur":【描边步骤 5.5】描边外扩后的选区深度(selectDepthBlurTextureTarget,
11
- * RGBA8 的 R 通道存对数深度,外扩环取邻域最近深度)
12
10
  * ⚠️ 2026-10-18 皇上旨「所有的对象都要进入到深度图里面了,不需要用参数过滤掉了」⇒ 原 "excluded"
13
11
  * 档(把被排除的面叠红)连同 ssao_pass 的排除判据整条删除,已无「写不进深度图的面」可标。
12
+ * ⚠️ 2026-11-04 描边回到年初口径 ⇒ 原 "strokeSelectBlur" 档(步骤 5.5 外扩后的选区深度)连同
13
+ * 那条 min-blur 整条删除,管线里已无「外扩深度」可供显示。
14
14
  */
15
- export type DebugDepthMode = "none" | "global" | "ao" | "compare" | "judge" | "strokeSelect" | "strokeSelectBlur";
15
+ export type DebugDepthMode = "none" | "global" | "ao" | "compare" | "judge" | "strokeSelect";
16
16
  /**
17
17
  * 调试用全局状态:记录最近一次 glow_pass / stroke_pass 渲染的「选区自身深度图」(selectDepth)。
18
18
  * 供 ThreeBase 渲染末尾的「辉光深度比对」调试视图读取(R=全局 base 深度,G=选区 select 深度),
19
19
  * 让用户一眼看出到底是「全局 base 深度没写进」还是「选区自身深度没写进」。
20
20
  * 由各 pass 在渲染完 selectDepth 后写入;每帧会被最后一个被处理的可见对象覆盖(调试够用)。
21
- *
22
- * lastSelectBlurDepth:描边专属(步骤 5.5 外扩后的选区深度)。它是 RGBA8 目标(不是 DepthTexture),
23
- * 深度值存在 .r 通道里 —— 可视化时同样取 .r 即可。glow/cover/frosted 没有这一步,故只有 stroke_pass 写。
24
21
  */
25
22
  export declare const debugDepthState: {
26
23
  lastSelectDepth: DepthTexture | null;
27
- lastSelectBlurDepth: Texture | null;
28
24
  };
@@ -3,11 +3,7 @@
3
3
  * 供 ThreeBase 渲染末尾的「辉光深度比对」调试视图读取(R=全局 base 深度,G=选区 select 深度),
4
4
  * 让用户一眼看出到底是「全局 base 深度没写进」还是「选区自身深度没写进」。
5
5
  * 由各 pass 在渲染完 selectDepth 后写入;每帧会被最后一个被处理的可见对象覆盖(调试够用)。
6
- *
7
- * lastSelectBlurDepth:描边专属(步骤 5.5 外扩后的选区深度)。它是 RGBA8 目标(不是 DepthTexture),
8
- * 深度值存在 .r 通道里 —— 可视化时同样取 .r 即可。glow/cover/frosted 没有这一步,故只有 stroke_pass 写。
9
6
  */
10
7
  export const debugDepthState = {
11
8
  lastSelectDepth: null,
12
- lastSelectBlurDepth: null,
13
9
  };
@@ -1,4 +1,4 @@
1
- import type { DepthTexture, Texture } from "three";
1
+ import type { DepthTexture } from "three";
2
2
 
3
3
  /**
4
4
  * 深度调试预览模式(面板按钮与控制台 `window.__TB_DEBUG_DEPTH` 共用同一枚举):
@@ -8,10 +8,10 @@ import type { DepthTexture, Texture } from "three";
8
8
  * - "compare" :R=base 深度、G=选区 select 深度(对数反解后归一化)
9
9
  * - "judge" :R=base 原始深度、G=select 原始深度、B=判定结果(1=判为被遮挡应 discard)
10
10
  * - "strokeSelect" :【描边步骤 1】选区本体深度(selectDepthTexture,mask 那趟光栅化顺带写入)
11
- * - "strokeSelectBlur":【描边步骤 5.5】描边外扩后的选区深度(selectDepthBlurTextureTarget,
12
- * RGBA8 的 R 通道存对数深度,外扩环取邻域最近深度)
13
11
  * ⚠️ 2026-10-18 皇上旨「所有的对象都要进入到深度图里面了,不需要用参数过滤掉了」⇒ 原 "excluded"
14
12
  * 档(把被排除的面叠红)连同 ssao_pass 的排除判据整条删除,已无「写不进深度图的面」可标。
13
+ * ⚠️ 2026-11-04 描边回到年初口径 ⇒ 原 "strokeSelectBlur" 档(步骤 5.5 外扩后的选区深度)连同
14
+ * 那条 min-blur 整条删除,管线里已无「外扩深度」可供显示。
15
15
  */
16
16
  export type DebugDepthMode =
17
17
  | "none"
@@ -19,22 +19,16 @@ export type DebugDepthMode =
19
19
  | "ao"
20
20
  | "compare"
21
21
  | "judge"
22
- | "strokeSelect"
23
- | "strokeSelectBlur";
22
+ | "strokeSelect";
24
23
 
25
24
  /**
26
25
  * 调试用全局状态:记录最近一次 glow_pass / stroke_pass 渲染的「选区自身深度图」(selectDepth)。
27
26
  * 供 ThreeBase 渲染末尾的「辉光深度比对」调试视图读取(R=全局 base 深度,G=选区 select 深度),
28
27
  * 让用户一眼看出到底是「全局 base 深度没写进」还是「选区自身深度没写进」。
29
28
  * 由各 pass 在渲染完 selectDepth 后写入;每帧会被最后一个被处理的可见对象覆盖(调试够用)。
30
- *
31
- * lastSelectBlurDepth:描边专属(步骤 5.5 外扩后的选区深度)。它是 RGBA8 目标(不是 DepthTexture),
32
- * 深度值存在 .r 通道里 —— 可视化时同样取 .r 即可。glow/cover/frosted 没有这一步,故只有 stroke_pass 写。
33
29
  */
34
30
  export const debugDepthState: {
35
31
  lastSelectDepth: DepthTexture | null;
36
- lastSelectBlurDepth: Texture | null;
37
32
  } = {
38
33
  lastSelectDepth: null,
39
- lastSelectBlurDepth: null,
40
34
  };
@@ -38,12 +38,6 @@ const selectDepthTestTextureTarget = new WebGLRenderTarget(1024, 1024, {
38
38
  });
39
39
  const blur1TextureTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
40
40
  const blur2TextureTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
41
- // 外扩深度临时/结果目标:depthMinBlurShader 把本体深度图随描边一起外扩,
42
- // 外扩区取邻域最近深度(忽略背景 1.0),使外扩区 selD<1.0,depthTest 才能正确判遮挡(不漏光)。
43
- const selectDepthBlurTmpTextureTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
44
- const selectDepthBlurTextureTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
45
- // 外扩描边经深度测试后的结果(步骤 5.6):独立 target,避免回写 blur1 触发历史回归 bug
46
- const blurDepthTestTextureTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
47
41
  const size = new Vector2();
48
42
  const depthTestShader = new ShaderMaterial({
49
43
  uniforms: {
@@ -383,52 +377,6 @@ const strokeShader = new RawShaderMaterial({
383
377
  depthWrite: false,
384
378
  transparent: true
385
379
  });
386
- // 深度 min-blur(最近深度,忽略背景 1.0):随描边一起把深度图向外扩,
387
- // 外扩区 selD = 物体最近深度(<1.0) 而非清屏 1.0,depthTest 在外扩区才能正确判遮挡(不漏光),
388
- // 同时本体区仍靠 depthTestShader 的 EPSILON 容差防自遮挡闪烁(无缝)。separable(direction 控制 H/V)。
389
- const depthMinBlurShader = new ShaderMaterial({
390
- uniforms: {
391
- tDepth: { value: null },
392
- resolution: { value: new Vector2(1 / 512, 1 / 512) },
393
- radius: { value: 1.0 },
394
- direction: { value: new Vector2(1, 0) },
395
- },
396
- vertexShader: `
397
- ${ShaderChunk.common}
398
- ${ShaderChunk.logdepthbuf_pars_vertex}
399
- varying vec2 vUv;
400
- void main() {
401
- vUv = uv;
402
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
403
- ${ShaderChunk.logdepthbuf_vertex}
404
- }
405
- `,
406
- fragmentShader: `
407
- ${ShaderChunk.logdepthbuf_pars_fragment}
408
- uniform sampler2D tDepth;
409
- uniform vec2 resolution;
410
- uniform float radius;
411
- uniform vec2 direction;
412
- varying vec2 vUv;
413
- void main() {
414
- float minD = 1.0;
415
- for (int i = -10; i <= 10; i++) {
416
- float x = float(i);
417
- vec2 off = direction * (x * radius * resolution);
418
- float d = texture2D(tDepth, vUv + off).r;
419
- if (d < 0.9999) {
420
- minD = min(minD, d);
421
- }
422
- }
423
- gl_FragColor = vec4(minD, 0.0, 0.0, 1.0);
424
- ${ShaderChunk.logdepthbuf_fragment}
425
- }
426
- `,
427
- blending: NoBlending,
428
- depthTest: false,
429
- depthWrite: false,
430
- transparent: true,
431
- });
432
380
  const filterSelectShader = new RawShaderMaterial({
433
381
  uniforms: {
434
382
  selectTexture: { value: null },
@@ -508,9 +456,6 @@ export default function stroke_pass(renderer, camera, object3d, baseDepthTexture
508
456
  selectDepthTestTextureTarget.setSize(size.x, size.y);
509
457
  blur1TextureTarget.setSize(size.x, size.y);
510
458
  blur2TextureTarget.setSize(size.x, size.y);
511
- selectDepthBlurTmpTextureTarget.setSize(size.x, size.y);
512
- selectDepthBlurTextureTarget.setSize(size.x, size.y);
513
- blurDepthTestTextureTarget.setSize(size.x, size.y);
514
459
  // 1. 渲染物体本体 mask(灰度)。⭐ 本体深度由同一趟光栅化顺带写进 selectDepthTexture
515
460
  // (挂在 selectTextureTarget 的 depth attachment 上)—— 原先这里还有第 2 趟「只渲深度」,
516
461
  // 同材质/同相机/同几何 ⇒ 深度逐位相同,纯重复遍历,2026-10-11 皇上旨简化时删除。
@@ -532,7 +477,8 @@ export default function stroke_pass(renderer, camera, object3d, baseDepthTexture
532
477
  depthTestShader.uniforms.cameraFar.value = camera.far;
533
478
  depthTestShader.uniforms.uDebugVis.value = (window.__STROKE_DEPTH_VIS) ? 1.0 : 0.0;
534
479
  fsQuad.render(renderer, depthTestShader);
535
- // 4. 从「深度测试后的本体描边」开始外扩(外扩环自动继承本体遮挡判定 → 不漏光),
480
+ // 4. 从「深度测试后的本体描边」开始外扩(外扩的源头是本体判过的结果,被挡掉的本体不会再长出描边;
481
+ // 但外扩出来那圈像素本身不再判深度 ⇒ 贴着遮挡物时会压到遮挡物前面,见步骤 6 的代价点名),
536
482
  // 描边粗细由 stroke_width 控制的迭代次数决定。
537
483
  strokeShader.uniforms.resolution.value.set(1 / size.x, 1 / size.y);
538
484
  FXAAShaderMaterial.uniforms.resolution.value.set(1 / size.x, 1 / size.y);
@@ -557,42 +503,14 @@ export default function stroke_pass(renderer, camera, object3d, baseDepthTexture
557
503
  renderer.setRenderTarget(blur1TextureTarget);
558
504
  FXAAShaderMaterial.uniforms.tDiffuse.value = blur2TextureTarget.texture;
559
505
  fsQuad.render(renderer, FXAAShaderMaterial);
560
- // 5.5 ⭐ 外扩环深度测试(补齐此前只定义未调用的死代码):
561
- // 本体深度随描边一起外扩(separable H/V,外扩区取邻域最近深度),
562
- // 再用它对外扩后的描边做一次 depthTest。
563
- // 缺了这一步,外扩环(描边的视觉主体)永远不参与遮挡判定 →
564
- // 物体被挡住时描边依旧从遮挡物前方漏出来("深度测试看起来失效"的真根因之一)。
565
- depthMinBlurShader.uniforms.resolution.value.set(1 / size.x, 1 / size.y);
566
- depthMinBlurShader.uniforms.radius.value = Math.max(1, stroke_width);
567
- // H:直接采 DepthTexture(selectDepthTexture),其 .r 即对数深度
568
- depthMinBlurShader.uniforms.direction.value.set(1, 0);
569
- depthMinBlurShader.uniforms.tDepth.value = selectDepthTexture;
570
- renderer.setRenderTarget(selectDepthBlurTmpTextureTarget);
571
- fsQuad.render(renderer, depthMinBlurShader);
572
- // V
573
- depthMinBlurShader.uniforms.direction.value.set(0, 1);
574
- depthMinBlurShader.uniforms.tDepth.value = selectDepthBlurTmpTextureTarget.texture;
575
- renderer.setRenderTarget(selectDepthBlurTextureTarget);
576
- fsQuad.render(renderer, depthMinBlurShader);
577
- // 调试:记录「外扩后的选区深度」,供面板「深度图(分步)」的步骤 5.5 档读取。
578
- // 它是 RGBA8 目标、对数深度存在 .r 里(与 DepthTexture 取 .r 的读法一致)。
579
- debugDepthState.lastSelectBlurDepth = selectDepthBlurTextureTarget.texture;
580
- // 5.6 对外扩后的描边判遮挡:被更近几何挡住的外扩环像素 discard。
581
- // ⚠️ 输出到独立 target,绝不回写 blur1(历史回归 bug:blur1 被 depthTest 结果覆盖
582
- // 会导致步骤 6 相减归零、描边全不显示)。
583
- renderer.setRenderTarget(blurDepthTestTextureTarget);
584
- renderer.clear();
585
- depthTestShader.uniforms.map.value = blur1TextureTarget.texture;
586
- depthTestShader.uniforms.baseDepth.value = baseDepthTexture;
587
- depthTestShader.uniforms.selectDepth.value = selectDepthBlurTextureTarget.texture;
588
- depthTestShader.uniforms.cameraFar.value = camera.far;
589
- depthTestShader.uniforms.uDebugVis.value = 0.0;
590
- fsQuad.render(renderer, depthTestShader);
591
- // 6. 合成:最终外扩描边(blurDepthTest,已判遮挡) 减去 深度测试后的本体描边
592
- // = 仅保留描边外扩环;本体被减掉。filterSelectShader 用 NormalBlending 叠到场景上。
506
+ // 6. 合成:FXAA 后的外扩描边(blur1) 减去 深度测试后的本体 = 仅保留描边外扩环;本体被减掉。
507
+ // ⚠️ 2026-11-04 决定回到「年初口径」:原先这里有 5.5(本体深度随描边一起 separable min-blur 外扩)
508
+ // 与 5.6(用外扩后的深度再判一次遮挡)两趟,作用是外扩环也参与遮挡判定 ⇒ 物体被挡住时描边不漏光。
509
+ // 撤掉换来的是每个描边对象每帧全屏绘制 10 趟降 7 趟(min-blur 那两趟是 ±10 共 21 次采样/像素,最贵);
510
+ // 代价点名:只有本体参与判遮挡,外扩环不参与 ⇒ 物体贴着遮挡物边缘时,一圈描边会压在遮挡物前面。
593
511
  renderer.setRenderTarget(originRenderTarget);
594
512
  filterSelectShader.uniforms.selectTexture.value = selectDepthTestTextureTarget.texture;
595
- filterSelectShader.uniforms.blurTexture.value = blurDepthTestTextureTarget.texture;
513
+ filterSelectShader.uniforms.blurTexture.value = blur1TextureTarget.texture;
596
514
  filterSelectShader.blending = NormalBlending;
597
515
  fsQuad.render(renderer, filterSelectShader);
598
516
  }
@@ -44,12 +44,6 @@ const selectDepthTestTextureTarget: WebGLRenderTarget = new WebGLRenderTarget(10
44
44
  const blur1TextureTarget: WebGLRenderTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
45
45
 
46
46
  const blur2TextureTarget: WebGLRenderTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
47
- // 外扩深度临时/结果目标:depthMinBlurShader 把本体深度图随描边一起外扩,
48
- // 外扩区取邻域最近深度(忽略背景 1.0),使外扩区 selD<1.0,depthTest 才能正确判遮挡(不漏光)。
49
- const selectDepthBlurTmpTextureTarget: WebGLRenderTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
50
- const selectDepthBlurTextureTarget: WebGLRenderTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
51
- // 外扩描边经深度测试后的结果(步骤 5.6):独立 target,避免回写 blur1 触发历史回归 bug
52
- const blurDepthTestTextureTarget: WebGLRenderTarget = new WebGLRenderTarget(1024, 1024, { minFilter: LinearFilter, magFilter: LinearFilter, format: RGBAFormat });
53
47
 
54
48
  const size: Vector2 = new Vector2();
55
49
 
@@ -394,53 +388,6 @@ const strokeShader: RawShaderMaterial = new RawShaderMaterial({
394
388
  transparent: true
395
389
  });
396
390
 
397
- // 深度 min-blur(最近深度,忽略背景 1.0):随描边一起把深度图向外扩,
398
- // 外扩区 selD = 物体最近深度(<1.0) 而非清屏 1.0,depthTest 在外扩区才能正确判遮挡(不漏光),
399
- // 同时本体区仍靠 depthTestShader 的 EPSILON 容差防自遮挡闪烁(无缝)。separable(direction 控制 H/V)。
400
- const depthMinBlurShader: ShaderMaterial = new ShaderMaterial({
401
- uniforms: {
402
- tDepth: { value: null },
403
- resolution: { value: new Vector2(1 / 512, 1 / 512) },
404
- radius: { value: 1.0 },
405
- direction: { value: new Vector2(1, 0) },
406
- },
407
- vertexShader: `
408
- ${ShaderChunk.common}
409
- ${ShaderChunk.logdepthbuf_pars_vertex}
410
- varying vec2 vUv;
411
- void main() {
412
- vUv = uv;
413
- gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
414
- ${ShaderChunk.logdepthbuf_vertex}
415
- }
416
- `,
417
- fragmentShader: `
418
- ${ShaderChunk.logdepthbuf_pars_fragment}
419
- uniform sampler2D tDepth;
420
- uniform vec2 resolution;
421
- uniform float radius;
422
- uniform vec2 direction;
423
- varying vec2 vUv;
424
- void main() {
425
- float minD = 1.0;
426
- for (int i = -10; i <= 10; i++) {
427
- float x = float(i);
428
- vec2 off = direction * (x * radius * resolution);
429
- float d = texture2D(tDepth, vUv + off).r;
430
- if (d < 0.9999) {
431
- minD = min(minD, d);
432
- }
433
- }
434
- gl_FragColor = vec4(minD, 0.0, 0.0, 1.0);
435
- ${ShaderChunk.logdepthbuf_fragment}
436
- }
437
- `,
438
- blending: NoBlending,
439
- depthTest: false,
440
- depthWrite: false,
441
- transparent: true,
442
- });
443
-
444
391
  const filterSelectShader: RawShaderMaterial = new RawShaderMaterial({
445
392
  uniforms: {
446
393
  selectTexture: { value: null },
@@ -536,9 +483,6 @@ export default function stroke_pass(
536
483
  selectDepthTestTextureTarget.setSize(size.x, size.y);
537
484
  blur1TextureTarget.setSize(size.x, size.y);
538
485
  blur2TextureTarget.setSize(size.x, size.y);
539
- selectDepthBlurTmpTextureTarget.setSize(size.x, size.y);
540
- selectDepthBlurTextureTarget.setSize(size.x, size.y);
541
- blurDepthTestTextureTarget.setSize(size.x, size.y);
542
486
 
543
487
  // 1. 渲染物体本体 mask(灰度)。⭐ 本体深度由同一趟光栅化顺带写进 selectDepthTexture
544
488
  // (挂在 selectTextureTarget 的 depth attachment 上)—— 原先这里还有第 2 趟「只渲深度」,
@@ -563,7 +507,8 @@ export default function stroke_pass(
563
507
  depthTestShader.uniforms.uDebugVis.value = ((window as unknown as { __STROKE_DEPTH_VIS?: boolean }).__STROKE_DEPTH_VIS) ? 1.0 : 0.0;
564
508
  fsQuad.render(renderer, depthTestShader);
565
509
 
566
- // 4. 从「深度测试后的本体描边」开始外扩(外扩环自动继承本体遮挡判定 → 不漏光),
510
+ // 4. 从「深度测试后的本体描边」开始外扩(外扩的源头是本体判过的结果,被挡掉的本体不会再长出描边;
511
+ // 但外扩出来那圈像素本身不再判深度 ⇒ 贴着遮挡物时会压到遮挡物前面,见步骤 6 的代价点名),
567
512
  // 描边粗细由 stroke_width 控制的迭代次数决定。
568
513
  strokeShader.uniforms.resolution.value.set(1 / size.x, 1 / size.y);
569
514
  FXAAShaderMaterial.uniforms.resolution.value.set(1 / size.x, 1 / size.y);
@@ -590,45 +535,14 @@ export default function stroke_pass(
590
535
  FXAAShaderMaterial.uniforms.tDiffuse.value = blur2TextureTarget.texture;
591
536
  fsQuad.render(renderer, FXAAShaderMaterial);
592
537
 
593
- // 5.5 ⭐ 外扩环深度测试(补齐此前只定义未调用的死代码):
594
- // 本体深度随描边一起外扩(separable H/V,外扩区取邻域最近深度),
595
- // 再用它对外扩后的描边做一次 depthTest。
596
- // 缺了这一步,外扩环(描边的视觉主体)永远不参与遮挡判定 →
597
- // 物体被挡住时描边依旧从遮挡物前方漏出来("深度测试看起来失效"的真根因之一)。
598
- depthMinBlurShader.uniforms.resolution.value.set(1 / size.x, 1 / size.y);
599
- depthMinBlurShader.uniforms.radius.value = Math.max(1, stroke_width);
600
- // H:直接采 DepthTexture(selectDepthTexture),其 .r 即对数深度
601
- depthMinBlurShader.uniforms.direction.value.set(1, 0);
602
- depthMinBlurShader.uniforms.tDepth.value = selectDepthTexture;
603
- renderer.setRenderTarget(selectDepthBlurTmpTextureTarget);
604
- fsQuad.render(renderer, depthMinBlurShader);
605
- // V
606
- depthMinBlurShader.uniforms.direction.value.set(0, 1);
607
- depthMinBlurShader.uniforms.tDepth.value = selectDepthBlurTmpTextureTarget.texture;
608
- renderer.setRenderTarget(selectDepthBlurTextureTarget);
609
- fsQuad.render(renderer, depthMinBlurShader);
610
-
611
- // 调试:记录「外扩后的选区深度」,供面板「深度图(分步)」的步骤 5.5 档读取。
612
- // 它是 RGBA8 目标、对数深度存在 .r 里(与 DepthTexture 取 .r 的读法一致)。
613
- debugDepthState.lastSelectBlurDepth = selectDepthBlurTextureTarget.texture;
614
-
615
- // 5.6 对外扩后的描边判遮挡:被更近几何挡住的外扩环像素 discard。
616
- // ⚠️ 输出到独立 target,绝不回写 blur1(历史回归 bug:blur1 被 depthTest 结果覆盖
617
- // 会导致步骤 6 相减归零、描边全不显示)。
618
- renderer.setRenderTarget(blurDepthTestTextureTarget);
619
- renderer.clear();
620
- depthTestShader.uniforms.map.value = blur1TextureTarget.texture;
621
- depthTestShader.uniforms.baseDepth.value = baseDepthTexture;
622
- depthTestShader.uniforms.selectDepth.value = selectDepthBlurTextureTarget.texture;
623
- depthTestShader.uniforms.cameraFar.value = (camera as PerspectiveCamera).far;
624
- depthTestShader.uniforms.uDebugVis.value = 0.0;
625
- fsQuad.render(renderer, depthTestShader);
626
-
627
- // 6. 合成:最终外扩描边(blurDepthTest,已判遮挡) 减去 深度测试后的本体描边
628
- // = 仅保留描边外扩环;本体被减掉。filterSelectShader 用 NormalBlending 叠到场景上。
538
+ // 6. 合成:FXAA 后的外扩描边(blur1) 减去 深度测试后的本体 = 仅保留描边外扩环;本体被减掉。
539
+ // ⚠️ 2026-11-04 决定回到「年初口径」:原先这里有 5.5(本体深度随描边一起 separable min-blur 外扩)
540
+ // 与 5.6(用外扩后的深度再判一次遮挡)两趟,作用是外扩环也参与遮挡判定 ⇒ 物体被挡住时描边不漏光。
541
+ // 撤掉换来的是每个描边对象每帧全屏绘制 10 趟降 7 趟(min-blur 那两趟是 ±10 共 21 次采样/像素,最贵);
542
+ // 代价点名:只有本体参与判遮挡,外扩环不参与 ⇒ 物体贴着遮挡物边缘时,一圈描边会压在遮挡物前面。
629
543
  renderer.setRenderTarget(originRenderTarget);
630
544
  filterSelectShader.uniforms.selectTexture.value = selectDepthTestTextureTarget.texture;
631
- filterSelectShader.uniforms.blurTexture.value = blurDepthTestTextureTarget.texture;
545
+ filterSelectShader.uniforms.blurTexture.value = blur1TextureTarget.texture;
632
546
  filterSelectShader.blending = NormalBlending;
633
547
  fsQuad.render(renderer, filterSelectShader);
634
548
  }