three-base 2.0.196 → 2.0.197
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.
- package/EventMap.d.ts +17 -0
- package/EventMap.ts +19 -0
- package/MktMesh/MktMesh.d.ts +61 -11
- package/MktMesh/MktMesh.js +200 -66
- package/MktMesh/MktMesh.ts +200 -62
- package/MktMesh/modelSlot.js +3 -0
- package/MktMesh/modelSlot.ts +3 -0
- package/MktMesh/panoBitmapTexture.js +5 -1
- package/MktMesh/panoBitmapTexture.ts +6 -1
- package/package.json +1 -1
- package/threeLoader.d.ts +14 -0
- package/threeLoader.js +48 -3
- package/threeLoader.ts +45 -3
package/EventMap.d.ts
CHANGED
|
@@ -20,6 +20,22 @@ export interface BeforeRenderEvent {
|
|
|
20
20
|
export interface AfterRenderEvent {
|
|
21
21
|
type: "afterRender";
|
|
22
22
|
}
|
|
23
|
+
export interface LoadErrorEvent {
|
|
24
|
+
type: "loadError";
|
|
25
|
+
url: string;
|
|
26
|
+
/** asset = 走 manager 计数的素材(全景整图/切片、平面瓦片、模型内嵌贴图);model = 模型槽那颗 GLB 整体没出来 */
|
|
27
|
+
kind: "asset" | "model";
|
|
28
|
+
/** 仅 kind="model":那一颗模型槽的名字,页面据此点名是哪一颗没出来 */
|
|
29
|
+
label?: string;
|
|
30
|
+
/** 全站累计失败地址数(与 manager 的 itemsTotal/itemsLoaded 同口径:累加、永不清零) */
|
|
31
|
+
failed: number;
|
|
32
|
+
/** 这个地址是不是第一次失败(同地址的后续重试仍发事件,但 isNew=false、不计数) */
|
|
33
|
+
isNew: boolean;
|
|
34
|
+
/** 服务端回的状态码(只在真的拿到过回复时有:全景/平面瓦片的 fetch 非 2xx、GLB 下载失败);网络层失败为 undefined */
|
|
35
|
+
status?: number;
|
|
36
|
+
/** 服务端**明说这个地址没有**(404 / 403)⇒ 页面据此把引用它的那条数据整条删掉;断网一类没有回复的失败不算 */
|
|
37
|
+
missing: boolean;
|
|
38
|
+
}
|
|
23
39
|
export interface RAFEvent {
|
|
24
40
|
type: "requestAnimationFrame";
|
|
25
41
|
timeTemp: number;
|
|
@@ -60,6 +76,7 @@ export interface ThreeLoaderEventMap extends Object3DEventMap {
|
|
|
60
76
|
"loadding": LoadingEvent;
|
|
61
77
|
"onLoad": OnLoadEvent;
|
|
62
78
|
"progress": LoaderProgressEvent;
|
|
79
|
+
"loadError": LoadErrorEvent;
|
|
63
80
|
}
|
|
64
81
|
export interface ThreeBaseEventTypeMap extends Object3DEventMap {
|
|
65
82
|
"loadding": LoadingEvent;
|
package/EventMap.ts
CHANGED
|
@@ -38,6 +38,24 @@ export interface LoaderProgressEvent {
|
|
|
38
38
|
}
|
|
39
39
|
export interface BeforeRenderEvent { type: "beforeRender"; }
|
|
40
40
|
export interface AfterRenderEvent { type: "afterRender"; }
|
|
41
|
+
// 资源加载失败:`DefaultLoadingManager.onError`(素材那一路)与模型槽取回失败(GLB 那一路)都汇到这一个出口。
|
|
42
|
+
// `url` 同一地址只计一次数(全景/平面瓦片失败会每帧重试 ⇒ 不去重会把失败数刷成无穷大)。
|
|
43
|
+
export interface LoadErrorEvent {
|
|
44
|
+
type: "loadError";
|
|
45
|
+
url: string;
|
|
46
|
+
/** asset = 走 manager 计数的素材(全景整图/切片、平面瓦片、模型内嵌贴图);model = 模型槽那颗 GLB 整体没出来 */
|
|
47
|
+
kind: "asset" | "model";
|
|
48
|
+
/** 仅 kind="model":那一颗模型槽的名字,页面据此点名是哪一颗没出来 */
|
|
49
|
+
label?: string;
|
|
50
|
+
/** 全站累计失败地址数(与 manager 的 itemsTotal/itemsLoaded 同口径:累加、永不清零) */
|
|
51
|
+
failed: number;
|
|
52
|
+
/** 这个地址是不是第一次失败(同地址的后续重试仍发事件,但 isNew=false、不计数) */
|
|
53
|
+
isNew: boolean;
|
|
54
|
+
/** 服务端回的状态码(只在真的拿到过回复时有:全景/平面瓦片的 fetch 非 2xx、GLB 下载失败);网络层失败为 undefined */
|
|
55
|
+
status?: number;
|
|
56
|
+
/** 服务端**明说这个地址没有**(404 / 403)⇒ 页面据此把引用它的那条数据整条删掉;断网一类没有回复的失败不算 */
|
|
57
|
+
missing: boolean;
|
|
58
|
+
}
|
|
41
59
|
|
|
42
60
|
export interface RAFEvent {
|
|
43
61
|
type: "requestAnimationFrame";
|
|
@@ -85,6 +103,7 @@ export interface ThreeLoaderEventMap extends Object3DEventMap {
|
|
|
85
103
|
"loadding": LoadingEvent;
|
|
86
104
|
"onLoad": OnLoadEvent;
|
|
87
105
|
"progress": LoaderProgressEvent;
|
|
106
|
+
"loadError": LoadErrorEvent;
|
|
88
107
|
}
|
|
89
108
|
|
|
90
109
|
export interface ThreeBaseEventTypeMap extends Object3DEventMap {
|
package/MktMesh/MktMesh.d.ts
CHANGED
|
@@ -163,14 +163,19 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
|
|
|
163
163
|
*/
|
|
164
164
|
private _revealPending;
|
|
165
165
|
/**
|
|
166
|
-
* 隐藏期 GPU 预热的在册清单(元素 =
|
|
166
|
+
* 隐藏期 GPU 预热的在册清单(元素 = 一个「会进渲染列表的物件」,即它的材质组合 × 几何)。
|
|
167
167
|
* null = 没在预热(未启动 / 已跑完 / `stopGpuPrewarm` / `dispose` 四种都是它)。
|
|
168
168
|
*/
|
|
169
169
|
private _warmItems;
|
|
170
|
-
/**
|
|
170
|
+
/** 已收过的去重键(`几何 × 材质数组 × 各材质的 map`)⇒ 跨轮次只补收新出现的组合,不重复编同一条 GL 路径。
|
|
171
|
+
* 自然收尾时**故意留着**(晚到的内容补排时靠它跳过旧档);只有 `stopGpuPrewarm`/`dispose` 把它归 null = 摘牌。 */
|
|
172
|
+
private _warmSeen;
|
|
173
|
+
/** 预热用的小场景:只挂当前这一件的替身 + 宿主灯光的克隆,`fog`/`environment` 照抄(都进 program 缓存键) */
|
|
171
174
|
private _warmScene;
|
|
172
|
-
/**
|
|
173
|
-
private
|
|
175
|
+
/** 此刻挂在预热小场景里的那一颗替身(一帧只画一件 ⇒ 换件时先把上一颗摘下来) */
|
|
176
|
+
private _warmActive;
|
|
177
|
+
/** 预热小场景里的那几盏灯 = 宿主灯的克隆体(各持一份 `shadow` ⇒ 收尾时释放的是它们自己的阴影图,碰不到宿主的) */
|
|
178
|
+
private _warmLights;
|
|
174
179
|
/** 预热落点:1×1 渲染目标(只有像素不要,要的是「这条 GL 路径确实走完了」),随预热结束/dispose 释放 */
|
|
175
180
|
private _warmRt;
|
|
176
181
|
constructor(opts?: {
|
|
@@ -411,7 +416,7 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
|
|
|
411
416
|
*/
|
|
412
417
|
tickProgressiveReveal(): void;
|
|
413
418
|
/**
|
|
414
|
-
*
|
|
419
|
+
* 开始隐藏期预热:把「进厅第一帧要现编的那些 program + 要第一次上传的那些几何/纹理」提前跑掉,一帧只放**一件**。
|
|
415
420
|
*
|
|
416
421
|
* 为什么必须在隐藏期做:`visible = false` 时 three 的遍历第一句就 `if (object.visible === false) return`
|
|
417
422
|
* (与上面 `tickProgressiveReveal` 注释同一颗雷)⇒ 墙根本进不了渲染列表,那份大 shader 从来没进过
|
|
@@ -424,22 +429,67 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
|
|
|
424
429
|
* 编出来的正是同一份 program(three 的缓存按 `材质 × 几何属性形态 × 场景光照/雾` 计键),
|
|
425
430
|
* 顶点缓冲也真上了 GPU;那 1×1 的像素直接扔掉,不占显存、不污染画面。
|
|
426
431
|
*
|
|
427
|
-
* ⚠️
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
432
|
+
* ⚠️ 2026-11-01 第二版:清单从「只收不带灯」放宽到**全收**,替身从「自造一颗裸 Mesh」换成「原对象自己克隆一颗」。
|
|
433
|
+
* 量出来的账(本地 dev,进厅三连帧 100/250/234 ms):进厅那几帧里 89 份几何第一次上传(`bufferData` ×72)、
|
|
434
|
+
* 504 次 `drawElements`、6 份 program 现编 —— 第一版一道都没挡住:展厅里的展品(`ModelSlot` 那些 GLB 件,
|
|
435
|
+
* Lambert/Standard 带灯材质)既没上传也没编,因为它们既不是 ShaderMaterial 也不是 MeshBasicMaterial。
|
|
436
|
+
* 带灯材质的 program 缓存键里有「方向光/点光/聚光数量 + 阴影档 + 环境图」
|
|
437
|
+
* (`three.module.js:20901-20913`:`numDirLights`…`numDirLightShadows`、`shadowMapType`,另有 `envMap`/`fog` 两个位)
|
|
438
|
+
* ⇒ 预热那间小屋子把宿主的灯**克隆一份**摆进去、`fog`/`environment` 照抄,否则编出来是另一份 define 组 = 白编。
|
|
439
|
+
* 替身必须是**克隆**不是裸 Mesh:`instancing`/`skinning`/`morphTargets` 也各占缓存键一个位
|
|
440
|
+
* (`getProgramCacheKeyBooleans` 里那段 `_programLayers`)⇒ 拿裸 Mesh 画实例化材质编的是非实例化那一份。
|
|
441
|
+
* 克隆的灯只活在预热期,收尾时连各自的阴影图一起释放(`light.clone()` 给的是 `shadow.clone()` 的新份,
|
|
442
|
+
* 实测不与宿主共享 ⇒ 不会误释放宿主那张);阴影边长压到 `GPU_WARM_SHADOW_MAP_SIZE`,理由写在那颗常量上。
|
|
443
|
+
*
|
|
444
|
+
* ⚠️ 一次性清单不等于收工:隐藏期里展厅的内容**还没长齐**(GLB 逐帧各挂一颗、平面的 4 格瓦片逐张就绪逐张绑),
|
|
445
|
+
* 所以补收有两只手 —— ① 排空那一拍再扫一遍;② 内容落位那两个落点各调一次 `_topUpGpuPrewarm()`
|
|
446
|
+
* (GLB 挂进子树、平面绑上瓦片)。去重键 = `几何 id × 材质 id 串 × 各材质的 map id`(平面再带绑定戳记)
|
|
447
|
+
* ⇒ 补收只针对「没见过的那一档」,同一件不会被画第二遍。
|
|
431
448
|
*
|
|
432
449
|
* @param renderer 宿主那一份渲染器(隐藏期 `onBeforeRender` 不触发,库里拿不到 ⇒ 必须由宿主交进来;
|
|
433
450
|
* 站侧的落点就是 `FloorMktBase.preloadFirstPano(renderer)`,它本来就是隐藏期的入口)
|
|
434
451
|
*/
|
|
435
452
|
startGpuPrewarm(renderer: WebGLRenderer): void;
|
|
436
|
-
/**
|
|
453
|
+
/** 停止隐藏期预热(宿主进入展厅时调):剩下的清单直接作废并**摘牌** —— 亮出来那一帧本来就要编要传,不必再抢额度;摘牌后晚到的内容也不再来补排。 */
|
|
437
454
|
stopGpuPrewarm(): void;
|
|
455
|
+
/**
|
|
456
|
+
* 内容后落位时补排预热(GLB 挂进子树那一拍、平面绑上瓦片纹理那一拍各调一次)。
|
|
457
|
+
*
|
|
458
|
+
* 为什么必须有这一颗:预热是隐藏期跑的,而隐藏期里展厅的内容**还没长齐** —— GLB 十几只各走一条异步下载链、
|
|
459
|
+
* 逐帧只挂一颗,平面那 4 格图也是逐张就绪逐张绑。一次性清单在几拍之内就排空了 ⇒ 晚到的那一族一件都没被画过,
|
|
460
|
+
* 进厅那一帧照样现编现传(这就是第一版「预热跑了、人还是卡了一下」的根)。
|
|
461
|
+
*
|
|
462
|
+
* 只在隐藏期响应:`_warmSeen` 归 null = 宿主已叫停(进厅)⇒ 晚到的东西交给「逐帧点亮」那一拍自己编,⛔ 别再抢额度。
|
|
463
|
+
*/
|
|
464
|
+
private _topUpGpuPrewarm;
|
|
465
|
+
/**
|
|
466
|
+
* 扫一遍本对象的子树,把「还没收过的那一档」压进清单。
|
|
467
|
+
* @returns 这次新收了几个(0 = 确实没有新东西 ⇒ 调用方据此收尾,⛔ 不是每帧空转)
|
|
468
|
+
*/
|
|
469
|
+
private _collectWarmItems;
|
|
438
470
|
/** 排下一件:先垫一个空函数占一格(全局队列每 tick 只跑一项 ⇒ 真活落在下一个 tick),与全景上传、平面绑定同一手法。 */
|
|
439
471
|
private _scheduleGpuWarmStep;
|
|
440
472
|
/** 队列回调的全部内容:预热**一件**(一次 1×1 绘制)。清单空了/已被摘牌就收尾。 */
|
|
441
473
|
private _runGpuWarmStep;
|
|
442
|
-
/**
|
|
474
|
+
/**
|
|
475
|
+
* 造(并记住)预热那间小屋子:只建一次。
|
|
476
|
+
* `fog` / `environment` 照抄、宿主的灯**逐盏克隆**摆进来 —— 这三样都进 program 缓存键,
|
|
477
|
+
* 少一样就是编出一份进厅用不上的 program。
|
|
478
|
+
*/
|
|
479
|
+
private _ensureWarmScene;
|
|
480
|
+
/** 沿 `parent` 往上找到的第一个 Scene = 宿主场景(本站是 `largeScreenRender` 那一棵)。 */
|
|
481
|
+
private _findHostScene;
|
|
482
|
+
/**
|
|
483
|
+
* 收尾(跑完 / 进厅 / 宿主叫停 / `dispose` 四条路都汇到这里):清单作废 + 临时资源拆掉。
|
|
484
|
+
*
|
|
485
|
+
* ⚠️ **不动 `_warmSeen`**:自然跑完那一路后面还会有内容落位(GLB、后绑的瓦片),补排时全靠这张表跳过已经画过的旧档
|
|
486
|
+
* —— 清掉它等于把同一批东西再画一遍。真正的「摘牌」只有 `stopGpuPrewarm`/`dispose`,它们先把这颗归 null。
|
|
487
|
+
*/
|
|
488
|
+
private _finishGpuPrewarm;
|
|
489
|
+
/**
|
|
490
|
+
* 拆掉预热那套临时资源(1×1 渲染目标 + 克隆的灯 + 小场景)。
|
|
491
|
+
* 替身只是从小屋子里摘出来 —— 它的 `geometry`/`material` 都是宿主那一份对象 ⇒ ⛔ 在此 dispose,会把展厅要用的资源释放掉。
|
|
492
|
+
*/
|
|
443
493
|
private _releaseWarmHarness;
|
|
444
494
|
/** 导出数据:墙点位 + 墙高 + 是否闭合 + 纹理数组(url + 矩阵)
|
|
445
495
|
* @param projName 项目名(可选,优先于 `setProjectName` 注入的那个)——图片地址落成 `{项目名}/xxx.jpg` */
|
package/MktMesh/MktMesh.js
CHANGED
|
@@ -56,6 +56,18 @@ import { ImagePlaneTileLoader } from "./imageTileLoader";
|
|
|
56
56
|
const PANO_IMPORT_PRIORITY = 2;
|
|
57
57
|
/** 隐藏期预热那一次绘制用的镜头(模块级共用:它只是个「有合法的投影矩阵可读」的载体,永不动 ⇒ ⛔ 不必每实例造一份)。 */
|
|
58
58
|
const GPU_WARM_CAMERA = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
|
|
59
|
+
/**
|
|
60
|
+
* 预热小屋子里那几盏克隆灯的**阴影图边长**(宿主一般是 4096²)。
|
|
61
|
+
* 压小不是降质量:这张图只在预热那间小屋子里用、像素当场扔掉,宿主自己那张阴影图归宿主每帧照画。
|
|
62
|
+
* 判据 = three 的 program 缓存键里只有 `shadowMapType` 与「几盏灯投影」这两档,**没有** `mapSize`
|
|
63
|
+
* (`three.module.js:20913` 一段 `numDirLightShadows / shadowMapType`,`mapSize` 只是 uniform)
|
|
64
|
+
* ⇒ 256² 编出来的 depth program 与 4096² 那份是同一个。
|
|
65
|
+
* 不压小的代价:预热每跑一件就等于替宿主重画一张 4096² 深度图(`shadow.autoUpdate` 默认开)
|
|
66
|
+
* ⇒ 隐藏期那圈自由视角跟着掉帧,那正是「卡」本身,⛔ 不能拿它换。
|
|
67
|
+
*/
|
|
68
|
+
const GPU_WARM_SHADOW_MAP_SIZE = 256;
|
|
69
|
+
/** 「同一件」判据里要看的那张主图 id:ShaderMaterial 那一族没有 `map` 这一档 ⇒ 恒 0;图片平面绑上图才换一档。 */
|
|
70
|
+
const warmMapId = (m) => m.map?.id ?? 0;
|
|
59
71
|
export default class MktMesh extends Mesh {
|
|
60
72
|
/** 纹理列表(每个纹理的 url + 世界矩阵) */
|
|
61
73
|
textures = [];
|
|
@@ -224,14 +236,19 @@ export default class MktMesh extends Mesh {
|
|
|
224
236
|
*/
|
|
225
237
|
_revealPending = [];
|
|
226
238
|
/**
|
|
227
|
-
* 隐藏期 GPU 预热的在册清单(元素 =
|
|
239
|
+
* 隐藏期 GPU 预热的在册清单(元素 = 一个「会进渲染列表的物件」,即它的材质组合 × 几何)。
|
|
228
240
|
* null = 没在预热(未启动 / 已跑完 / `stopGpuPrewarm` / `dispose` 四种都是它)。
|
|
229
241
|
*/
|
|
230
242
|
_warmItems = null;
|
|
231
|
-
/**
|
|
243
|
+
/** 已收过的去重键(`几何 × 材质数组 × 各材质的 map`)⇒ 跨轮次只补收新出现的组合,不重复编同一条 GL 路径。
|
|
244
|
+
* 自然收尾时**故意留着**(晚到的内容补排时靠它跳过旧档);只有 `stopGpuPrewarm`/`dispose` 把它归 null = 摘牌。 */
|
|
245
|
+
_warmSeen = null;
|
|
246
|
+
/** 预热用的小场景:只挂当前这一件的替身 + 宿主灯光的克隆,`fog`/`environment` 照抄(都进 program 缓存键) */
|
|
232
247
|
_warmScene = null;
|
|
233
|
-
/**
|
|
234
|
-
|
|
248
|
+
/** 此刻挂在预热小场景里的那一颗替身(一帧只画一件 ⇒ 换件时先把上一颗摘下来) */
|
|
249
|
+
_warmActive = null;
|
|
250
|
+
/** 预热小场景里的那几盏灯 = 宿主灯的克隆体(各持一份 `shadow` ⇒ 收尾时释放的是它们自己的阴影图,碰不到宿主的) */
|
|
251
|
+
_warmLights = null;
|
|
235
252
|
/** 预热落点:1×1 渲染目标(只有像素不要,要的是「这条 GL 路径确实走完了」),随预热结束/dispose 释放 */
|
|
236
253
|
_warmRt = null;
|
|
237
254
|
constructor(opts = {}) {
|
|
@@ -1203,6 +1220,8 @@ export default class MktMesh extends Mesh {
|
|
|
1203
1220
|
return;
|
|
1204
1221
|
if (ms.mountStaged() && onEachLoad)
|
|
1205
1222
|
onEachLoad();
|
|
1223
|
+
// 挂进来的那一族此刻才在子树里 ⇒ 补排预热(清单已排空时,这一颗是重新开工的唯一触发点)
|
|
1224
|
+
this._topUpGpuPrewarm();
|
|
1206
1225
|
}
|
|
1207
1226
|
/**
|
|
1208
1227
|
* 把各颗平面那 4 张瓦片的纹理绑进材质(10-22 晚定的口径:不走拼合器 RT)。
|
|
@@ -1264,6 +1283,9 @@ export default class MktMesh extends Mesh {
|
|
|
1264
1283
|
}
|
|
1265
1284
|
}
|
|
1266
1285
|
ip.setTileTextures(texs, insets);
|
|
1286
|
+
// 瓦片是一张一张就绪的 ⇒ 每绑上一张,这一颗的「待画纹理」就换一档(去重键带绑定戳记)⇒ 补排一次,
|
|
1287
|
+
// 让上传发生在隐藏期而不是进厅那一帧。
|
|
1288
|
+
this._topUpGpuPrewarm();
|
|
1267
1289
|
if (pending) {
|
|
1268
1290
|
this._queueImagePlaneBind(ip);
|
|
1269
1291
|
return;
|
|
@@ -1291,7 +1313,7 @@ export default class MktMesh extends Mesh {
|
|
|
1291
1313
|
}
|
|
1292
1314
|
// ─────────────────── 隐藏期 GPU 预热(2026-11-01 需求:进厅那一下不要再现编着色器) ───────────────────
|
|
1293
1315
|
/**
|
|
1294
|
-
*
|
|
1316
|
+
* 开始隐藏期预热:把「进厅第一帧要现编的那些 program + 要第一次上传的那些几何/纹理」提前跑掉,一帧只放**一件**。
|
|
1295
1317
|
*
|
|
1296
1318
|
* 为什么必须在隐藏期做:`visible = false` 时 three 的遍历第一句就 `if (object.visible === false) return`
|
|
1297
1319
|
* (与上面 `tickProgressiveReveal` 注释同一颗雷)⇒ 墙根本进不了渲染列表,那份大 shader 从来没进过
|
|
@@ -1304,10 +1326,22 @@ export default class MktMesh extends Mesh {
|
|
|
1304
1326
|
* 编出来的正是同一份 program(three 的缓存按 `材质 × 几何属性形态 × 场景光照/雾` 计键),
|
|
1305
1327
|
* 顶点缓冲也真上了 GPU;那 1×1 的像素直接扔掉,不占显存、不污染画面。
|
|
1306
1328
|
*
|
|
1307
|
-
* ⚠️
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
*
|
|
1329
|
+
* ⚠️ 2026-11-01 第二版:清单从「只收不带灯」放宽到**全收**,替身从「自造一颗裸 Mesh」换成「原对象自己克隆一颗」。
|
|
1330
|
+
* 量出来的账(本地 dev,进厅三连帧 100/250/234 ms):进厅那几帧里 89 份几何第一次上传(`bufferData` ×72)、
|
|
1331
|
+
* 504 次 `drawElements`、6 份 program 现编 —— 第一版一道都没挡住:展厅里的展品(`ModelSlot` 那些 GLB 件,
|
|
1332
|
+
* Lambert/Standard 带灯材质)既没上传也没编,因为它们既不是 ShaderMaterial 也不是 MeshBasicMaterial。
|
|
1333
|
+
* 带灯材质的 program 缓存键里有「方向光/点光/聚光数量 + 阴影档 + 环境图」
|
|
1334
|
+
* (`three.module.js:20901-20913`:`numDirLights`…`numDirLightShadows`、`shadowMapType`,另有 `envMap`/`fog` 两个位)
|
|
1335
|
+
* ⇒ 预热那间小屋子把宿主的灯**克隆一份**摆进去、`fog`/`environment` 照抄,否则编出来是另一份 define 组 = 白编。
|
|
1336
|
+
* 替身必须是**克隆**不是裸 Mesh:`instancing`/`skinning`/`morphTargets` 也各占缓存键一个位
|
|
1337
|
+
* (`getProgramCacheKeyBooleans` 里那段 `_programLayers`)⇒ 拿裸 Mesh 画实例化材质编的是非实例化那一份。
|
|
1338
|
+
* 克隆的灯只活在预热期,收尾时连各自的阴影图一起释放(`light.clone()` 给的是 `shadow.clone()` 的新份,
|
|
1339
|
+
* 实测不与宿主共享 ⇒ 不会误释放宿主那张);阴影边长压到 `GPU_WARM_SHADOW_MAP_SIZE`,理由写在那颗常量上。
|
|
1340
|
+
*
|
|
1341
|
+
* ⚠️ 一次性清单不等于收工:隐藏期里展厅的内容**还没长齐**(GLB 逐帧各挂一颗、平面的 4 格瓦片逐张就绪逐张绑),
|
|
1342
|
+
* 所以补收有两只手 —— ① 排空那一拍再扫一遍;② 内容落位那两个落点各调一次 `_topUpGpuPrewarm()`
|
|
1343
|
+
* (GLB 挂进子树、平面绑上瓦片)。去重键 = `几何 id × 材质 id 串 × 各材质的 map id`(平面再带绑定戳记)
|
|
1344
|
+
* ⇒ 补收只针对「没见过的那一档」,同一件不会被画第二遍。
|
|
1311
1345
|
*
|
|
1312
1346
|
* @param renderer 宿主那一份渲染器(隐藏期 `onBeforeRender` 不触发,库里拿不到 ⇒ 必须由宿主交进来;
|
|
1313
1347
|
* 站侧的落点就是 `FloorMktBase.preloadFirstPano(renderer)`,它本来就是隐藏期的入口)
|
|
@@ -1320,38 +1354,68 @@ export default class MktMesh extends Mesh {
|
|
|
1320
1354
|
this._glRenderer = renderer;
|
|
1321
1355
|
if (this._warmItems)
|
|
1322
1356
|
return; // 已在预热 ⇒ ⛔ 不重建清单(重复点会打断还没跑的那几件)
|
|
1323
|
-
|
|
1324
|
-
|
|
1357
|
+
this._warmItems = [];
|
|
1358
|
+
this._warmSeen = new Set();
|
|
1359
|
+
this._collectWarmItems();
|
|
1360
|
+
this._scheduleGpuWarmStep(renderer);
|
|
1361
|
+
}
|
|
1362
|
+
/** 停止隐藏期预热(宿主进入展厅时调):剩下的清单直接作废并**摘牌** —— 亮出来那一帧本来就要编要传,不必再抢额度;摘牌后晚到的内容也不再来补排。 */
|
|
1363
|
+
stopGpuPrewarm() {
|
|
1364
|
+
this._warmSeen = null; // 这颗就是「摘牌」本身:`_topUpGpuPrewarm` 只认它
|
|
1365
|
+
this._finishGpuPrewarm();
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* 内容后落位时补排预热(GLB 挂进子树那一拍、平面绑上瓦片纹理那一拍各调一次)。
|
|
1369
|
+
*
|
|
1370
|
+
* 为什么必须有这一颗:预热是隐藏期跑的,而隐藏期里展厅的内容**还没长齐** —— GLB 十几只各走一条异步下载链、
|
|
1371
|
+
* 逐帧只挂一颗,平面那 4 格图也是逐张就绪逐张绑。一次性清单在几拍之内就排空了 ⇒ 晚到的那一族一件都没被画过,
|
|
1372
|
+
* 进厅那一帧照样现编现传(这就是第一版「预热跑了、人还是卡了一下」的根)。
|
|
1373
|
+
*
|
|
1374
|
+
* 只在隐藏期响应:`_warmSeen` 归 null = 宿主已叫停(进厅)⇒ 晚到的东西交给「逐帧点亮」那一拍自己编,⛔ 别再抢额度。
|
|
1375
|
+
*/
|
|
1376
|
+
_topUpGpuPrewarm() {
|
|
1377
|
+
if (!this._warmSeen || this.visible)
|
|
1378
|
+
return;
|
|
1379
|
+
if (!this._warmItems) {
|
|
1380
|
+
this._warmItems = []; // 上一轮已经收尾 ⇒ 重新开工(去重表还在 ⇒ 只补新出现的那几档,旧的不会重画)
|
|
1381
|
+
this._scheduleGpuWarmStep(this._glRenderer); // `_warmSeen` 非空 ⇒ `startGpuPrewarm` 早已把渲染器交进来
|
|
1382
|
+
}
|
|
1383
|
+
this._collectWarmItems();
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* 扫一遍本对象的子树,把「还没收过的那一档」压进清单。
|
|
1387
|
+
* @returns 这次新收了几个(0 = 确实没有新东西 ⇒ 调用方据此收尾,⛔ 不是每帧空转)
|
|
1388
|
+
*/
|
|
1389
|
+
_collectWarmItems() {
|
|
1390
|
+
const items = this._warmItems;
|
|
1391
|
+
const seen = this._warmSeen;
|
|
1392
|
+
if (!items || !seen)
|
|
1393
|
+
return 0;
|
|
1394
|
+
let added = 0;
|
|
1395
|
+
// ⚠️ 遍历**不看 `visible`**(与 `_ensureWarmScene` 里那趟剪枝的灯正好相反):隐藏期里展品的槽、图片的层都是
|
|
1396
|
+
// 先压着不亮的(`setModels` 那一路进来就是 `visible = false`),要挡它们就等于把这次要治的那一族又漏回去。
|
|
1325
1397
|
this.traverse((o) => {
|
|
1326
1398
|
const mesh = o;
|
|
1327
1399
|
if (!mesh.isMesh)
|
|
1328
1400
|
return;
|
|
1329
|
-
const m = mesh.material;
|
|
1330
1401
|
const g = mesh.geometry;
|
|
1331
|
-
if (!
|
|
1332
|
-
return;
|
|
1333
|
-
// 白名单,不是黑名单:r167 里「带不带灯」不再写在 `material.lights` 上(除 ShaderMaterial 之外全是 undefined),
|
|
1334
|
-
// 只有这两个是本站这条链上确定不吃场景光照的 ⇒ 别的材质类型一律不收。
|
|
1335
|
-
if (!m.isShaderMaterial && !m.isMeshBasicMaterial)
|
|
1336
|
-
return;
|
|
1337
|
-
// 平面的图还没绑上(`map` 这一档还空着)⇒ 跳过:那样编出来的是 `USE_MAP` 关闭的那一份,
|
|
1338
|
-
// 与绑上图后真正要用的不是同一个 program,白编(绑图那一拍会重新排进 `_imgBindQueued`,照样亮得出)。
|
|
1339
|
-
const withMap = m;
|
|
1340
|
-
if ("map" in withMap && !withMap.map)
|
|
1402
|
+
if (!g)
|
|
1341
1403
|
return;
|
|
1342
|
-
const
|
|
1404
|
+
const mats = (Array.isArray(mesh.material) ? mesh.material : [mesh.material]);
|
|
1405
|
+
if (!mats.length || mats.some((m) => !m || !m.visible))
|
|
1406
|
+
return; // 材质自己就不画 ⇒ 编了也用不上
|
|
1407
|
+
// 「同一件」= 同一份几何 × 同一串材质 × 同一串图。后两截都要进键,因为 program 缓存键里 `USE_MAP` 那一档
|
|
1408
|
+
// 看的是材质真绑没绑;而图片平面那一族的 4 格瓦片走的是 `_uTiles` uniform 数组(`material.map` 恒 null),
|
|
1409
|
+
// 绑上第 1 张那一刻才是「这条 GL 路径第一次真画」⇒ 拿它自带的绑定戳记当尺,图一换就补收一次。
|
|
1410
|
+
const key = `${g.id}|${mats.map((m) => m.id).join("+")}|${mats.map(warmMapId).join("+")}` +
|
|
1411
|
+
(o instanceof ImagePlane ? `|${o.tilesBindingStamp()}` : "");
|
|
1343
1412
|
if (seen.has(key))
|
|
1344
|
-
return;
|
|
1413
|
+
return;
|
|
1345
1414
|
seen.add(key);
|
|
1346
|
-
items.push({ m, g });
|
|
1415
|
+
items.push({ src: o, m: mesh.material, g });
|
|
1416
|
+
added++;
|
|
1347
1417
|
});
|
|
1348
|
-
|
|
1349
|
-
this._scheduleGpuWarmStep(renderer);
|
|
1350
|
-
}
|
|
1351
|
-
/** 停止隐藏期预热(宿主进入展厅时调):剩下的清单直接作废 —— 亮出来那一帧本来就要编要传,不必再抢额度。 */
|
|
1352
|
-
stopGpuPrewarm() {
|
|
1353
|
-
this._warmItems = null;
|
|
1354
|
-
this._releaseWarmHarness();
|
|
1418
|
+
return added;
|
|
1355
1419
|
}
|
|
1356
1420
|
/** 排下一件:先垫一个空函数占一格(全局队列每 tick 只跑一项 ⇒ 真活落在下一个 tick),与全景上传、平面绑定同一手法。 */
|
|
1357
1421
|
_scheduleGpuWarmStep(renderer) {
|
|
@@ -1364,55 +1428,125 @@ export default class MktMesh extends Mesh {
|
|
|
1364
1428
|
if (!items)
|
|
1365
1429
|
return; // `stopGpuPrewarm`/`dispose` 已摘牌 ⇒ 本项作废
|
|
1366
1430
|
if (this.visible) { // 已经亮起来了(进厅)⇒ 不再和真实那帧抢额度
|
|
1367
|
-
this.
|
|
1368
|
-
this._releaseWarmHarness();
|
|
1431
|
+
this._finishGpuPrewarm();
|
|
1369
1432
|
return;
|
|
1370
1433
|
}
|
|
1371
|
-
if (!items.length) {
|
|
1372
|
-
this.
|
|
1373
|
-
this._releaseWarmHarness();
|
|
1434
|
+
if (!items.length && this._collectWarmItems() === 0) { // 排空了:先补扫(GLB、后绑的图都是晚到的),真没有才收尾
|
|
1435
|
+
this._finishGpuPrewarm();
|
|
1374
1436
|
return;
|
|
1375
1437
|
}
|
|
1376
1438
|
const it = items.shift();
|
|
1377
|
-
|
|
1378
|
-
let
|
|
1379
|
-
if (!proxy
|
|
1380
|
-
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
if (p.isScene) {
|
|
1386
|
-
host = p;
|
|
1387
|
-
break;
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
|
-
scene.fog = host ? host.fog : null;
|
|
1391
|
-
proxy = new Mesh();
|
|
1439
|
+
const scene = this._ensureWarmScene();
|
|
1440
|
+
let proxy = it.p;
|
|
1441
|
+
if (!proxy) {
|
|
1442
|
+
// 替身 = **原对象自己克隆一颗**(⛔ 自造裸 `Mesh`):`instancing`/`skinning`/`morphTargets` 都进缓存键,
|
|
1443
|
+
// 裸 Mesh 画实例化材质编的是非实例化那一份 = 白编。克隆体与宿主共享同一份 `geometry`/`material` 对象
|
|
1444
|
+
// ⇒ 编的是同一个 program、传的是同一个顶点缓冲。子树不要(`recursive = false`):一件只画一颗。
|
|
1445
|
+
proxy = it.p = it.src.clone(false);
|
|
1446
|
+
proxy.visible = true; // 宿主那颗此刻多半还是 `visible = false` ⇒ 替身得进得了 `projectObject`
|
|
1392
1447
|
proxy.frustumCulled = false; // 墙几何是世界坐标、体量又比这 2×2 的取景框大得多 ⇒ 别按包围球剔除
|
|
1393
|
-
scene.add(proxy);
|
|
1394
|
-
this._warmScene = scene;
|
|
1395
|
-
this._warmProxy = proxy;
|
|
1396
1448
|
}
|
|
1397
|
-
if (
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1449
|
+
if (it.src.isInstancedMesh) {
|
|
1450
|
+
const src = it.src;
|
|
1451
|
+
const inst = proxy;
|
|
1452
|
+
// `clone()` 里的 `instanceMatrix.copy(...)` 是**快照**(另起一份 Float32Array)⇒ 改回指宿主那一份:
|
|
1453
|
+
// 上传的就是进厅要用的那个 buffer,晚到的落位改动也一起带上
|
|
1454
|
+
inst.instanceMatrix = src.instanceMatrix;
|
|
1455
|
+
inst.count = src.count;
|
|
1456
|
+
}
|
|
1457
|
+
if (this._warmActive && this._warmActive !== proxy)
|
|
1458
|
+
scene.remove(this._warmActive); // 一帧只画这一件
|
|
1459
|
+
if (proxy.parent !== scene)
|
|
1460
|
+
scene.add(proxy);
|
|
1461
|
+
this._warmActive = proxy;
|
|
1401
1462
|
const prevTarget = renderer.getRenderTarget();
|
|
1402
1463
|
renderer.setRenderTarget(this._warmRt);
|
|
1403
1464
|
renderer.render(scene, GPU_WARM_CAMERA);
|
|
1404
1465
|
renderer.setRenderTarget(prevTarget);
|
|
1405
1466
|
this._scheduleGpuWarmStep(renderer);
|
|
1406
1467
|
}
|
|
1407
|
-
/**
|
|
1468
|
+
/**
|
|
1469
|
+
* 造(并记住)预热那间小屋子:只建一次。
|
|
1470
|
+
* `fog` / `environment` 照抄、宿主的灯**逐盏克隆**摆进来 —— 这三样都进 program 缓存键,
|
|
1471
|
+
* 少一样就是编出一份进厅用不上的 program。
|
|
1472
|
+
*/
|
|
1473
|
+
_ensureWarmScene() {
|
|
1474
|
+
if (this._warmScene)
|
|
1475
|
+
return this._warmScene;
|
|
1476
|
+
const scene = new Scene();
|
|
1477
|
+
const lights = [];
|
|
1478
|
+
const host = this._findHostScene();
|
|
1479
|
+
if (host) {
|
|
1480
|
+
scene.fog = host.fog; // `USE_FOG` 进键
|
|
1481
|
+
scene.environment = host.environment; // `envMap` 进键(Standard 那族吃场景环境图)
|
|
1482
|
+
const walk = (o) => {
|
|
1483
|
+
if (!o.visible)
|
|
1484
|
+
return; // 与 three 的 `projectObject` 同一道闸:此刻不计数的灯,进厅那份 define 组里也没有
|
|
1485
|
+
const light = o;
|
|
1486
|
+
if (light.isLight) {
|
|
1487
|
+
const clone = light.clone(true);
|
|
1488
|
+
// `light.clone()` 里 `shadow = source.shadow.clone()` ⇒ 克隆体自带一份**新的** shadow
|
|
1489
|
+
// (边长、bias、投影相机范围都照抄,实测不与宿主共享),所以这里只管把它压小;
|
|
1490
|
+
// 压小的理由与「为什么压了还编得出同一份 program」都写在 `GPU_WARM_SHADOW_MAP_SIZE` 上。
|
|
1491
|
+
const shadow = clone.shadow;
|
|
1492
|
+
if (shadow?.mapSize)
|
|
1493
|
+
shadow.mapSize.set(GPU_WARM_SHADOW_MAP_SIZE, GPU_WARM_SHADOW_MAP_SIZE);
|
|
1494
|
+
// target 同样是新克隆的一份(位置一起带过来)⇒ 挂进这间屋子,`updateMatrixWorld` 才走得到它、
|
|
1495
|
+
// 光照方向才与宿主一致。⛔ 不许把宿主那颗挂过来:`Object3D.add` 会先从原父节点 `remove`,
|
|
1496
|
+
// 楼外那圈自由视角的阴影当场就被摘歪了。
|
|
1497
|
+
const target = clone.target;
|
|
1498
|
+
if (target)
|
|
1499
|
+
scene.add(target);
|
|
1500
|
+
scene.add(clone);
|
|
1501
|
+
lights.push(clone);
|
|
1502
|
+
}
|
|
1503
|
+
for (const child of o.children)
|
|
1504
|
+
walk(child);
|
|
1505
|
+
};
|
|
1506
|
+
walk(host);
|
|
1507
|
+
}
|
|
1508
|
+
this._warmLights = lights;
|
|
1509
|
+
this._warmScene = scene;
|
|
1510
|
+
if (!this._warmRt)
|
|
1511
|
+
this._warmRt = new WebGLRenderTarget(1, 1);
|
|
1512
|
+
return scene;
|
|
1513
|
+
}
|
|
1514
|
+
/** 沿 `parent` 往上找到的第一个 Scene = 宿主场景(本站是 `largeScreenRender` 那一棵)。 */
|
|
1515
|
+
_findHostScene() {
|
|
1516
|
+
for (let p = this.parent; p; p = p.parent) {
|
|
1517
|
+
if (p.isScene)
|
|
1518
|
+
return p;
|
|
1519
|
+
}
|
|
1520
|
+
return null;
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* 收尾(跑完 / 进厅 / 宿主叫停 / `dispose` 四条路都汇到这里):清单作废 + 临时资源拆掉。
|
|
1524
|
+
*
|
|
1525
|
+
* ⚠️ **不动 `_warmSeen`**:自然跑完那一路后面还会有内容落位(GLB、后绑的瓦片),补排时全靠这张表跳过已经画过的旧档
|
|
1526
|
+
* —— 清掉它等于把同一批东西再画一遍。真正的「摘牌」只有 `stopGpuPrewarm`/`dispose`,它们先把这颗归 null。
|
|
1527
|
+
*/
|
|
1528
|
+
_finishGpuPrewarm() {
|
|
1529
|
+
this._warmItems = null;
|
|
1530
|
+
this._releaseWarmHarness();
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* 拆掉预热那套临时资源(1×1 渲染目标 + 克隆的灯 + 小场景)。
|
|
1534
|
+
* 替身只是从小屋子里摘出来 —— 它的 `geometry`/`material` 都是宿主那一份对象 ⇒ ⛔ 在此 dispose,会把展厅要用的资源释放掉。
|
|
1535
|
+
*/
|
|
1408
1536
|
_releaseWarmHarness() {
|
|
1409
1537
|
if (this._warmRt)
|
|
1410
1538
|
this._warmRt.dispose();
|
|
1411
1539
|
this._warmRt = null;
|
|
1412
|
-
|
|
1413
|
-
|
|
1540
|
+
for (const light of this._warmLights || []) {
|
|
1541
|
+
const shadow = light.shadow;
|
|
1542
|
+
if (shadow?.dispose)
|
|
1543
|
+
shadow.dispose(); // 放掉的是克隆体自己那张阴影图(`clone()` 给的是新 shadow)
|
|
1544
|
+
}
|
|
1545
|
+
this._warmLights = null;
|
|
1546
|
+
if (this._warmScene)
|
|
1547
|
+
this._warmScene.clear(); // 只摘孩子(替身、克隆灯、灯的 target)
|
|
1414
1548
|
this._warmScene = null;
|
|
1415
|
-
this.
|
|
1549
|
+
this._warmActive = null;
|
|
1416
1550
|
}
|
|
1417
1551
|
/** 导出数据:墙点位 + 墙高 + 是否闭合 + 纹理数组(url + 矩阵)
|
|
1418
1552
|
* @param projName 项目名(可选,优先于 `setProjectName` 注入的那个)——图片地址落成 `{项目名}/xxx.jpg` */
|
|
@@ -1580,9 +1714,9 @@ export default class MktMesh extends Mesh {
|
|
|
1580
1714
|
this._imgBindDone.clear();
|
|
1581
1715
|
this._modelMountQueued.clear();
|
|
1582
1716
|
this._revealPending = [];
|
|
1583
|
-
//
|
|
1584
|
-
|
|
1585
|
-
this.
|
|
1717
|
+
// 隐藏期预热:清单、去重表与小屋子那套临时资源随本对象一起退场(那张 1×1 渲染目标是本类**唯一**自建的 RT)。
|
|
1718
|
+
// 走 `stopGpuPrewarm` 而不是 `_finishGpuPrewarm`:本对象都要没了,去重表再留着就是泄漏。
|
|
1719
|
+
this.stopGpuPrewarm();
|
|
1586
1720
|
this._stitcher.dispose();
|
|
1587
1721
|
this._glRenderer = null;
|
|
1588
1722
|
if (this._placeholder)
|
package/MktMesh/MktMesh.ts
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
Camera,
|
|
30
30
|
BackSide,
|
|
31
31
|
Float32BufferAttribute,
|
|
32
|
+
InstancedMesh,
|
|
33
|
+
Light,
|
|
32
34
|
Material,
|
|
33
35
|
Matrix4,
|
|
34
36
|
Object3D,
|
|
@@ -83,6 +85,23 @@ const PANO_IMPORT_PRIORITY = 2;
|
|
|
83
85
|
/** 隐藏期预热那一次绘制用的镜头(模块级共用:它只是个「有合法的投影矩阵可读」的载体,永不动 ⇒ ⛔ 不必每实例造一份)。 */
|
|
84
86
|
const GPU_WARM_CAMERA = new OrthographicCamera(-1, 1, 1, -1, 0.1, 10);
|
|
85
87
|
|
|
88
|
+
/**
|
|
89
|
+
* 预热小屋子里那几盏克隆灯的**阴影图边长**(宿主一般是 4096²)。
|
|
90
|
+
* 压小不是降质量:这张图只在预热那间小屋子里用、像素当场扔掉,宿主自己那张阴影图归宿主每帧照画。
|
|
91
|
+
* 判据 = three 的 program 缓存键里只有 `shadowMapType` 与「几盏灯投影」这两档,**没有** `mapSize`
|
|
92
|
+
* (`three.module.js:20913` 一段 `numDirLightShadows / shadowMapType`,`mapSize` 只是 uniform)
|
|
93
|
+
* ⇒ 256² 编出来的 depth program 与 4096² 那份是同一个。
|
|
94
|
+
* 不压小的代价:预热每跑一件就等于替宿主重画一张 4096² 深度图(`shadow.autoUpdate` 默认开)
|
|
95
|
+
* ⇒ 隐藏期那圈自由视角跟着掉帧,那正是「卡」本身,⛔ 不能拿它换。
|
|
96
|
+
*/
|
|
97
|
+
const GPU_WARM_SHADOW_MAP_SIZE = 256;
|
|
98
|
+
|
|
99
|
+
/** 只取预热要动的那两颗字段(`LightShadow` 的类型名不在这页的导入清单里,结构类型够用且不必扩面)。 */
|
|
100
|
+
type WarmShadow = { mapSize?: { set(w: number, h: number): unknown }; dispose?(): void };
|
|
101
|
+
|
|
102
|
+
/** 「同一件」判据里要看的那张主图 id:ShaderMaterial 那一族没有 `map` 这一档 ⇒ 恒 0;图片平面绑上图才换一档。 */
|
|
103
|
+
const warmMapId = (m: Material): number => (m as Material & { map?: Texture | null }).map?.id ?? 0;
|
|
104
|
+
|
|
86
105
|
export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
|
|
87
106
|
/** 纹理列表(每个纹理的 url + 世界矩阵) */
|
|
88
107
|
textures: MktTextureInfo[] = [];
|
|
@@ -257,14 +276,19 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
|
|
|
257
276
|
private _revealPending: Array<ImagePlane | ModelSlot> = [];
|
|
258
277
|
|
|
259
278
|
/**
|
|
260
|
-
* 隐藏期 GPU 预热的在册清单(元素 =
|
|
279
|
+
* 隐藏期 GPU 预热的在册清单(元素 = 一个「会进渲染列表的物件」,即它的材质组合 × 几何)。
|
|
261
280
|
* null = 没在预热(未启动 / 已跑完 / `stopGpuPrewarm` / `dispose` 四种都是它)。
|
|
262
281
|
*/
|
|
263
|
-
private _warmItems: Array<{ m: Material; g: BufferGeometry }> | null = null;
|
|
264
|
-
/**
|
|
282
|
+
private _warmItems: Array<{ src: Object3D; m: Material | Material[]; g: BufferGeometry; p?: Object3D }> | null = null;
|
|
283
|
+
/** 已收过的去重键(`几何 × 材质数组 × 各材质的 map`)⇒ 跨轮次只补收新出现的组合,不重复编同一条 GL 路径。
|
|
284
|
+
* 自然收尾时**故意留着**(晚到的内容补排时靠它跳过旧档);只有 `stopGpuPrewarm`/`dispose` 把它归 null = 摘牌。 */
|
|
285
|
+
private _warmSeen: Set<string> | null = null;
|
|
286
|
+
/** 预热用的小场景:只挂当前这一件的替身 + 宿主灯光的克隆,`fog`/`environment` 照抄(都进 program 缓存键) */
|
|
265
287
|
private _warmScene: Scene | null = null;
|
|
266
|
-
/**
|
|
267
|
-
private
|
|
288
|
+
/** 此刻挂在预热小场景里的那一颗替身(一帧只画一件 ⇒ 换件时先把上一颗摘下来) */
|
|
289
|
+
private _warmActive: Object3D | null = null;
|
|
290
|
+
/** 预热小场景里的那几盏灯 = 宿主灯的克隆体(各持一份 `shadow` ⇒ 收尾时释放的是它们自己的阴影图,碰不到宿主的) */
|
|
291
|
+
private _warmLights: Light[] | null = null;
|
|
268
292
|
/** 预热落点:1×1 渲染目标(只有像素不要,要的是「这条 GL 路径确实走完了」),随预热结束/dispose 释放 */
|
|
269
293
|
private _warmRt: WebGLRenderTarget | null = null;
|
|
270
294
|
|
|
@@ -1243,6 +1267,8 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
|
|
|
1243
1267
|
// 槽已退场(换项目 / 删除):待挂那棵由 `ModelSlot.dispose` → `_detachModel` 当场释放,这里不挂
|
|
1244
1268
|
if (!this.models.includes(ms)) return;
|
|
1245
1269
|
if (ms.mountStaged() && onEachLoad) onEachLoad();
|
|
1270
|
+
// 挂进来的那一族此刻才在子树里 ⇒ 补排预热(清单已排空时,这一颗是重新开工的唯一触发点)
|
|
1271
|
+
this._topUpGpuPrewarm();
|
|
1246
1272
|
}
|
|
1247
1273
|
|
|
1248
1274
|
/**
|
|
@@ -1300,6 +1326,9 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
|
|
|
1300
1326
|
}
|
|
1301
1327
|
}
|
|
1302
1328
|
ip.setTileTextures(texs, insets);
|
|
1329
|
+
// 瓦片是一张一张就绪的 ⇒ 每绑上一张,这一颗的「待画纹理」就换一档(去重键带绑定戳记)⇒ 补排一次,
|
|
1330
|
+
// 让上传发生在隐藏期而不是进厅那一帧。
|
|
1331
|
+
this._topUpGpuPrewarm();
|
|
1303
1332
|
if (pending) {
|
|
1304
1333
|
this._queueImagePlaneBind(ip);
|
|
1305
1334
|
return;
|
|
@@ -1329,7 +1358,7 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
|
|
|
1329
1358
|
// ─────────────────── 隐藏期 GPU 预热(2026-11-01 需求:进厅那一下不要再现编着色器) ───────────────────
|
|
1330
1359
|
|
|
1331
1360
|
/**
|
|
1332
|
-
*
|
|
1361
|
+
* 开始隐藏期预热:把「进厅第一帧要现编的那些 program + 要第一次上传的那些几何/纹理」提前跑掉,一帧只放**一件**。
|
|
1333
1362
|
*
|
|
1334
1363
|
* 为什么必须在隐藏期做:`visible = false` 时 three 的遍历第一句就 `if (object.visible === false) return`
|
|
1335
1364
|
* (与上面 `tickProgressiveReveal` 注释同一颗雷)⇒ 墙根本进不了渲染列表,那份大 shader 从来没进过
|
|
@@ -1342,10 +1371,22 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
|
|
|
1342
1371
|
* 编出来的正是同一份 program(three 的缓存按 `材质 × 几何属性形态 × 场景光照/雾` 计键),
|
|
1343
1372
|
* 顶点缓冲也真上了 GPU;那 1×1 的像素直接扔掉,不占显存、不污染画面。
|
|
1344
1373
|
*
|
|
1345
|
-
* ⚠️
|
|
1346
|
-
*
|
|
1347
|
-
*
|
|
1348
|
-
*
|
|
1374
|
+
* ⚠️ 2026-11-01 第二版:清单从「只收不带灯」放宽到**全收**,替身从「自造一颗裸 Mesh」换成「原对象自己克隆一颗」。
|
|
1375
|
+
* 量出来的账(本地 dev,进厅三连帧 100/250/234 ms):进厅那几帧里 89 份几何第一次上传(`bufferData` ×72)、
|
|
1376
|
+
* 504 次 `drawElements`、6 份 program 现编 —— 第一版一道都没挡住:展厅里的展品(`ModelSlot` 那些 GLB 件,
|
|
1377
|
+
* Lambert/Standard 带灯材质)既没上传也没编,因为它们既不是 ShaderMaterial 也不是 MeshBasicMaterial。
|
|
1378
|
+
* 带灯材质的 program 缓存键里有「方向光/点光/聚光数量 + 阴影档 + 环境图」
|
|
1379
|
+
* (`three.module.js:20901-20913`:`numDirLights`…`numDirLightShadows`、`shadowMapType`,另有 `envMap`/`fog` 两个位)
|
|
1380
|
+
* ⇒ 预热那间小屋子把宿主的灯**克隆一份**摆进去、`fog`/`environment` 照抄,否则编出来是另一份 define 组 = 白编。
|
|
1381
|
+
* 替身必须是**克隆**不是裸 Mesh:`instancing`/`skinning`/`morphTargets` 也各占缓存键一个位
|
|
1382
|
+
* (`getProgramCacheKeyBooleans` 里那段 `_programLayers`)⇒ 拿裸 Mesh 画实例化材质编的是非实例化那一份。
|
|
1383
|
+
* 克隆的灯只活在预热期,收尾时连各自的阴影图一起释放(`light.clone()` 给的是 `shadow.clone()` 的新份,
|
|
1384
|
+
* 实测不与宿主共享 ⇒ 不会误释放宿主那张);阴影边长压到 `GPU_WARM_SHADOW_MAP_SIZE`,理由写在那颗常量上。
|
|
1385
|
+
*
|
|
1386
|
+
* ⚠️ 一次性清单不等于收工:隐藏期里展厅的内容**还没长齐**(GLB 逐帧各挂一颗、平面的 4 格瓦片逐张就绪逐张绑),
|
|
1387
|
+
* 所以补收有两只手 —— ① 排空那一拍再扫一遍;② 内容落位那两个落点各调一次 `_topUpGpuPrewarm()`
|
|
1388
|
+
* (GLB 挂进子树、平面绑上瓦片)。去重键 = `几何 id × 材质 id 串 × 各材质的 map id`(平面再带绑定戳记)
|
|
1389
|
+
* ⇒ 补收只针对「没见过的那一档」,同一件不会被画第二遍。
|
|
1349
1390
|
*
|
|
1350
1391
|
* @param renderer 宿主那一份渲染器(隐藏期 `onBeforeRender` 不触发,库里拿不到 ⇒ 必须由宿主交进来;
|
|
1351
1392
|
* 站侧的落点就是 `FloorMktBase.preloadFirstPano(renderer)`,它本来就是隐藏期的入口)
|
|
@@ -1356,35 +1397,65 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
|
|
|
1356
1397
|
//(原先 `_glRenderer` 只在 `onBeforeRender` 里捕获 ⇒ 隐藏期排上的队全走「three 自己绑」的降级路)。
|
|
1357
1398
|
this._glRenderer = renderer;
|
|
1358
1399
|
if (this._warmItems) return; // 已在预热 ⇒ ⛔ 不重建清单(重复点会打断还没跑的那几件)
|
|
1400
|
+
this._warmItems = [];
|
|
1401
|
+
this._warmSeen = new Set<string>();
|
|
1402
|
+
this._collectWarmItems();
|
|
1403
|
+
this._scheduleGpuWarmStep(renderer);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/** 停止隐藏期预热(宿主进入展厅时调):剩下的清单直接作废并**摘牌** —— 亮出来那一帧本来就要编要传,不必再抢额度;摘牌后晚到的内容也不再来补排。 */
|
|
1407
|
+
stopGpuPrewarm(): void {
|
|
1408
|
+
this._warmSeen = null; // 这颗就是「摘牌」本身:`_topUpGpuPrewarm` 只认它
|
|
1409
|
+
this._finishGpuPrewarm();
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* 内容后落位时补排预热(GLB 挂进子树那一拍、平面绑上瓦片纹理那一拍各调一次)。
|
|
1414
|
+
*
|
|
1415
|
+
* 为什么必须有这一颗:预热是隐藏期跑的,而隐藏期里展厅的内容**还没长齐** —— GLB 十几只各走一条异步下载链、
|
|
1416
|
+
* 逐帧只挂一颗,平面那 4 格图也是逐张就绪逐张绑。一次性清单在几拍之内就排空了 ⇒ 晚到的那一族一件都没被画过,
|
|
1417
|
+
* 进厅那一帧照样现编现传(这就是第一版「预热跑了、人还是卡了一下」的根)。
|
|
1418
|
+
*
|
|
1419
|
+
* 只在隐藏期响应:`_warmSeen` 归 null = 宿主已叫停(进厅)⇒ 晚到的东西交给「逐帧点亮」那一拍自己编,⛔ 别再抢额度。
|
|
1420
|
+
*/
|
|
1421
|
+
private _topUpGpuPrewarm(): void {
|
|
1422
|
+
if (!this._warmSeen || this.visible) return;
|
|
1423
|
+
if (!this._warmItems) {
|
|
1424
|
+
this._warmItems = []; // 上一轮已经收尾 ⇒ 重新开工(去重表还在 ⇒ 只补新出现的那几档,旧的不会重画)
|
|
1425
|
+
this._scheduleGpuWarmStep(this._glRenderer!); // `_warmSeen` 非空 ⇒ `startGpuPrewarm` 早已把渲染器交进来
|
|
1426
|
+
}
|
|
1427
|
+
this._collectWarmItems();
|
|
1428
|
+
}
|
|
1359
1429
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1430
|
+
/**
|
|
1431
|
+
* 扫一遍本对象的子树,把「还没收过的那一档」压进清单。
|
|
1432
|
+
* @returns 这次新收了几个(0 = 确实没有新东西 ⇒ 调用方据此收尾,⛔ 不是每帧空转)
|
|
1433
|
+
*/
|
|
1434
|
+
private _collectWarmItems(): number {
|
|
1435
|
+
const items = this._warmItems;
|
|
1436
|
+
const seen = this._warmSeen;
|
|
1437
|
+
if (!items || !seen) return 0;
|
|
1438
|
+
let added = 0;
|
|
1439
|
+
// ⚠️ 遍历**不看 `visible`**(与 `_ensureWarmScene` 里那趟剪枝的灯正好相反):隐藏期里展品的槽、图片的层都是
|
|
1440
|
+
// 先压着不亮的(`setModels` 那一路进来就是 `visible = false`),要挡它们就等于把这次要治的那一族又漏回去。
|
|
1362
1441
|
this.traverse((o) => {
|
|
1363
1442
|
const mesh = o as Mesh;
|
|
1364
1443
|
if (!mesh.isMesh) return;
|
|
1365
|
-
const m = mesh.material as Material;
|
|
1366
1444
|
const g = mesh.geometry as BufferGeometry;
|
|
1367
|
-
if (!
|
|
1368
|
-
|
|
1369
|
-
//
|
|
1370
|
-
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
const
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
if (seen.has(key)) return; // 同一份材质 + 同一份几何 = 同一个 program,画一次就够
|
|
1445
|
+
if (!g) return;
|
|
1446
|
+
const mats = (Array.isArray(mesh.material) ? mesh.material : [mesh.material]) as Material[];
|
|
1447
|
+
if (!mats.length || mats.some((m) => !m || !m.visible)) return; // 材质自己就不画 ⇒ 编了也用不上
|
|
1448
|
+
// 「同一件」= 同一份几何 × 同一串材质 × 同一串图。后两截都要进键,因为 program 缓存键里 `USE_MAP` 那一档
|
|
1449
|
+
// 看的是材质真绑没绑;而图片平面那一族的 4 格瓦片走的是 `_uTiles` uniform 数组(`material.map` 恒 null),
|
|
1450
|
+
// 绑上第 1 张那一刻才是「这条 GL 路径第一次真画」⇒ 拿它自带的绑定戳记当尺,图一换就补收一次。
|
|
1451
|
+
const key = `${g.id}|${mats.map((m) => m.id).join("+")}|${mats.map(warmMapId).join("+")}` +
|
|
1452
|
+
(o instanceof ImagePlane ? `|${(o as ImagePlane).tilesBindingStamp()}` : "");
|
|
1453
|
+
if (seen.has(key)) return;
|
|
1377
1454
|
seen.add(key);
|
|
1378
|
-
items.push({ m, g });
|
|
1455
|
+
items.push({ src: o, m: mesh.material as Material | Material[], g });
|
|
1456
|
+
added++;
|
|
1379
1457
|
});
|
|
1380
|
-
|
|
1381
|
-
this._scheduleGpuWarmStep(renderer);
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
/** 停止隐藏期预热(宿主进入展厅时调):剩下的清单直接作废 —— 亮出来那一帧本来就要编要传,不必再抢额度。 */
|
|
1385
|
-
stopGpuPrewarm(): void {
|
|
1386
|
-
this._warmItems = null;
|
|
1387
|
-
this._releaseWarmHarness();
|
|
1458
|
+
return added;
|
|
1388
1459
|
}
|
|
1389
1460
|
|
|
1390
1461
|
/** 排下一件:先垫一个空函数占一格(全局队列每 tick 只跑一项 ⇒ 真活落在下一个 tick),与全景上传、平面绑定同一手法。 */
|
|
@@ -1398,50 +1469,117 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
|
|
|
1398
1469
|
const items = this._warmItems;
|
|
1399
1470
|
if (!items) return; // `stopGpuPrewarm`/`dispose` 已摘牌 ⇒ 本项作废
|
|
1400
1471
|
if (this.visible) { // 已经亮起来了(进厅)⇒ 不再和真实那帧抢额度
|
|
1401
|
-
this.
|
|
1402
|
-
this._releaseWarmHarness();
|
|
1472
|
+
this._finishGpuPrewarm();
|
|
1403
1473
|
return;
|
|
1404
1474
|
}
|
|
1405
|
-
if (!items.length) {
|
|
1406
|
-
this.
|
|
1407
|
-
this._releaseWarmHarness();
|
|
1475
|
+
if (!items.length && this._collectWarmItems() === 0) { // 排空了:先补扫(GLB、后绑的图都是晚到的),真没有才收尾
|
|
1476
|
+
this._finishGpuPrewarm();
|
|
1408
1477
|
return;
|
|
1409
1478
|
}
|
|
1410
1479
|
const it = items.shift()!;
|
|
1411
|
-
|
|
1412
|
-
let
|
|
1413
|
-
if (!proxy
|
|
1414
|
-
|
|
1415
|
-
//
|
|
1416
|
-
//
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
if ((p as Scene).isScene) { host = p as Scene; break; }
|
|
1420
|
-
}
|
|
1421
|
-
scene.fog = host ? host.fog : null;
|
|
1422
|
-
proxy = new Mesh();
|
|
1480
|
+
const scene = this._ensureWarmScene();
|
|
1481
|
+
let proxy = it.p;
|
|
1482
|
+
if (!proxy) {
|
|
1483
|
+
// 替身 = **原对象自己克隆一颗**(⛔ 自造裸 `Mesh`):`instancing`/`skinning`/`morphTargets` 都进缓存键,
|
|
1484
|
+
// 裸 Mesh 画实例化材质编的是非实例化那一份 = 白编。克隆体与宿主共享同一份 `geometry`/`material` 对象
|
|
1485
|
+
// ⇒ 编的是同一个 program、传的是同一个顶点缓冲。子树不要(`recursive = false`):一件只画一颗。
|
|
1486
|
+
proxy = it.p = it.src.clone(false);
|
|
1487
|
+
proxy.visible = true; // 宿主那颗此刻多半还是 `visible = false` ⇒ 替身得进得了 `projectObject`
|
|
1423
1488
|
proxy.frustumCulled = false; // 墙几何是世界坐标、体量又比这 2×2 的取景框大得多 ⇒ 别按包围球剔除
|
|
1424
|
-
scene.add(proxy);
|
|
1425
|
-
this._warmScene = scene;
|
|
1426
|
-
this._warmProxy = proxy;
|
|
1427
1489
|
}
|
|
1428
|
-
if (
|
|
1429
|
-
|
|
1430
|
-
|
|
1490
|
+
if ((it.src as InstancedMesh).isInstancedMesh) {
|
|
1491
|
+
const src = it.src as InstancedMesh;
|
|
1492
|
+
const inst = proxy as InstancedMesh;
|
|
1493
|
+
// `clone()` 里的 `instanceMatrix.copy(...)` 是**快照**(另起一份 Float32Array)⇒ 改回指宿主那一份:
|
|
1494
|
+
// 上传的就是进厅要用的那个 buffer,晚到的落位改动也一起带上
|
|
1495
|
+
inst.instanceMatrix = src.instanceMatrix;
|
|
1496
|
+
inst.count = src.count;
|
|
1497
|
+
}
|
|
1498
|
+
if (this._warmActive && this._warmActive !== proxy) scene.remove(this._warmActive); // 一帧只画这一件
|
|
1499
|
+
if (proxy.parent !== scene) scene.add(proxy);
|
|
1500
|
+
this._warmActive = proxy;
|
|
1431
1501
|
const prevTarget = renderer.getRenderTarget();
|
|
1432
|
-
renderer.setRenderTarget(this._warmRt);
|
|
1502
|
+
renderer.setRenderTarget(this._warmRt!);
|
|
1433
1503
|
renderer.render(scene, GPU_WARM_CAMERA);
|
|
1434
1504
|
renderer.setRenderTarget(prevTarget);
|
|
1435
1505
|
this._scheduleGpuWarmStep(renderer);
|
|
1436
1506
|
}
|
|
1437
1507
|
|
|
1438
|
-
/**
|
|
1508
|
+
/**
|
|
1509
|
+
* 造(并记住)预热那间小屋子:只建一次。
|
|
1510
|
+
* `fog` / `environment` 照抄、宿主的灯**逐盏克隆**摆进来 —— 这三样都进 program 缓存键,
|
|
1511
|
+
* 少一样就是编出一份进厅用不上的 program。
|
|
1512
|
+
*/
|
|
1513
|
+
private _ensureWarmScene(): Scene {
|
|
1514
|
+
if (this._warmScene) return this._warmScene;
|
|
1515
|
+
const scene = new Scene();
|
|
1516
|
+
const lights: Light[] = [];
|
|
1517
|
+
const host = this._findHostScene();
|
|
1518
|
+
if (host) {
|
|
1519
|
+
scene.fog = host.fog; // `USE_FOG` 进键
|
|
1520
|
+
scene.environment = host.environment; // `envMap` 进键(Standard 那族吃场景环境图)
|
|
1521
|
+
const walk = (o: Object3D) => {
|
|
1522
|
+
if (!o.visible) return; // 与 three 的 `projectObject` 同一道闸:此刻不计数的灯,进厅那份 define 组里也没有
|
|
1523
|
+
const light = o as Light;
|
|
1524
|
+
if (light.isLight) {
|
|
1525
|
+
const clone = light.clone(true) as Light;
|
|
1526
|
+
// `light.clone()` 里 `shadow = source.shadow.clone()` ⇒ 克隆体自带一份**新的** shadow
|
|
1527
|
+
// (边长、bias、投影相机范围都照抄,实测不与宿主共享),所以这里只管把它压小;
|
|
1528
|
+
// 压小的理由与「为什么压了还编得出同一份 program」都写在 `GPU_WARM_SHADOW_MAP_SIZE` 上。
|
|
1529
|
+
const shadow = (clone as unknown as { shadow?: WarmShadow }).shadow;
|
|
1530
|
+
if (shadow?.mapSize) shadow.mapSize.set(GPU_WARM_SHADOW_MAP_SIZE, GPU_WARM_SHADOW_MAP_SIZE);
|
|
1531
|
+
// target 同样是新克隆的一份(位置一起带过来)⇒ 挂进这间屋子,`updateMatrixWorld` 才走得到它、
|
|
1532
|
+
// 光照方向才与宿主一致。⛔ 不许把宿主那颗挂过来:`Object3D.add` 会先从原父节点 `remove`,
|
|
1533
|
+
// 楼外那圈自由视角的阴影当场就被摘歪了。
|
|
1534
|
+
const target = (clone as unknown as { target?: Object3D }).target;
|
|
1535
|
+
if (target) scene.add(target);
|
|
1536
|
+
scene.add(clone);
|
|
1537
|
+
lights.push(clone);
|
|
1538
|
+
}
|
|
1539
|
+
for (const child of o.children) walk(child);
|
|
1540
|
+
};
|
|
1541
|
+
walk(host);
|
|
1542
|
+
}
|
|
1543
|
+
this._warmLights = lights;
|
|
1544
|
+
this._warmScene = scene;
|
|
1545
|
+
if (!this._warmRt) this._warmRt = new WebGLRenderTarget(1, 1);
|
|
1546
|
+
return scene;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/** 沿 `parent` 往上找到的第一个 Scene = 宿主场景(本站是 `largeScreenRender` 那一棵)。 */
|
|
1550
|
+
private _findHostScene(): Scene | null {
|
|
1551
|
+
for (let p: Object3D | null = this.parent; p; p = p.parent) {
|
|
1552
|
+
if ((p as Scene).isScene) return p as Scene;
|
|
1553
|
+
}
|
|
1554
|
+
return null;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/**
|
|
1558
|
+
* 收尾(跑完 / 进厅 / 宿主叫停 / `dispose` 四条路都汇到这里):清单作废 + 临时资源拆掉。
|
|
1559
|
+
*
|
|
1560
|
+
* ⚠️ **不动 `_warmSeen`**:自然跑完那一路后面还会有内容落位(GLB、后绑的瓦片),补排时全靠这张表跳过已经画过的旧档
|
|
1561
|
+
* —— 清掉它等于把同一批东西再画一遍。真正的「摘牌」只有 `stopGpuPrewarm`/`dispose`,它们先把这颗归 null。
|
|
1562
|
+
*/
|
|
1563
|
+
private _finishGpuPrewarm(): void {
|
|
1564
|
+
this._warmItems = null;
|
|
1565
|
+
this._releaseWarmHarness();
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* 拆掉预热那套临时资源(1×1 渲染目标 + 克隆的灯 + 小场景)。
|
|
1570
|
+
* 替身只是从小屋子里摘出来 —— 它的 `geometry`/`material` 都是宿主那一份对象 ⇒ ⛔ 在此 dispose,会把展厅要用的资源释放掉。
|
|
1571
|
+
*/
|
|
1439
1572
|
private _releaseWarmHarness(): void {
|
|
1440
1573
|
if (this._warmRt) this._warmRt.dispose();
|
|
1441
1574
|
this._warmRt = null;
|
|
1442
|
-
|
|
1575
|
+
for (const light of this._warmLights || []) {
|
|
1576
|
+
const shadow = (light as unknown as { shadow?: WarmShadow }).shadow;
|
|
1577
|
+
if (shadow?.dispose) shadow.dispose(); // 放掉的是克隆体自己那张阴影图(`clone()` 给的是新 shadow)
|
|
1578
|
+
}
|
|
1579
|
+
this._warmLights = null;
|
|
1580
|
+
if (this._warmScene) this._warmScene.clear(); // 只摘孩子(替身、克隆灯、灯的 target)
|
|
1443
1581
|
this._warmScene = null;
|
|
1444
|
-
this.
|
|
1582
|
+
this._warmActive = null;
|
|
1445
1583
|
}
|
|
1446
1584
|
|
|
1447
1585
|
/** 导出数据:墙点位 + 墙高 + 是否闭合 + 纹理数组(url + 矩阵)
|
|
@@ -1617,9 +1755,9 @@ export default class MktMesh extends Mesh<BufferGeometry, ShaderMaterial> {
|
|
|
1617
1755
|
this._imgBindDone.clear();
|
|
1618
1756
|
this._modelMountQueued.clear();
|
|
1619
1757
|
this._revealPending = [];
|
|
1620
|
-
//
|
|
1621
|
-
|
|
1622
|
-
this.
|
|
1758
|
+
// 隐藏期预热:清单、去重表与小屋子那套临时资源随本对象一起退场(那张 1×1 渲染目标是本类**唯一**自建的 RT)。
|
|
1759
|
+
// 走 `stopGpuPrewarm` 而不是 `_finishGpuPrewarm`:本对象都要没了,去重表再留着就是泄漏。
|
|
1760
|
+
this.stopGpuPrewarm();
|
|
1623
1761
|
this._stitcher.dispose();
|
|
1624
1762
|
this._glRenderer = null;
|
|
1625
1763
|
if (this._placeholder) this._placeholder.dispose();
|
package/MktMesh/modelSlot.js
CHANGED
|
@@ -195,6 +195,9 @@ export class ModelSlot extends Group {
|
|
|
195
195
|
return false;
|
|
196
196
|
}
|
|
197
197
|
if (!obj) {
|
|
198
|
+
// 画面停在占位方块之外,还要让页面知道「这一颗没出来」:报进加载状态那唯一出口,带上槽名。
|
|
199
|
+
// 同一地址重复失败由 `threeLoader.reportLoadError` 去重(下载本身失败已由 manager 计过一次 ⇒ 这里只补「是哪颗槽」)。
|
|
200
|
+
threeLoader.reportLoadError(url, "model", this.name);
|
|
198
201
|
this._loaded = true;
|
|
199
202
|
this._layout();
|
|
200
203
|
return false;
|
package/MktMesh/modelSlot.ts
CHANGED
|
@@ -233,6 +233,9 @@ export class ModelSlot extends Group {
|
|
|
233
233
|
return false;
|
|
234
234
|
}
|
|
235
235
|
if (!obj) {
|
|
236
|
+
// 画面停在占位方块之外,还要让页面知道「这一颗没出来」:报进加载状态那唯一出口,带上槽名。
|
|
237
|
+
// 同一地址重复失败由 `threeLoader.reportLoadError` 去重(下载本身失败已由 manager 计过一次 ⇒ 这里只补「是哪颗槽」)。
|
|
238
|
+
threeLoader.reportLoadError(url, "model", this.name);
|
|
236
239
|
this._loaded = true;
|
|
237
240
|
this._layout();
|
|
238
241
|
return false;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DefaultLoadingManager, Texture } from "three";
|
|
2
|
+
import threeLoader from "../threeLoader";
|
|
2
3
|
/**
|
|
3
4
|
* ⭐ 甲(皇上旨 2026-10-05「你试试甲」):一张全景图(整图 / 单片瓦片,现行 32 片/全景)的**异步解码**建纹理。
|
|
4
5
|
*
|
|
@@ -25,8 +26,11 @@ export function loadPanoTexture(url) {
|
|
|
25
26
|
DefaultLoadingManager.itemStart(url);
|
|
26
27
|
const task = fetch(url)
|
|
27
28
|
.then((res) => {
|
|
28
|
-
|
|
29
|
+
// 状态码先记进 `threeLoader`(页面据此分「服务器明说没有」与「断网」:前者才敢删那条数据)
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
threeLoader.markUrlStatus(url, res.status);
|
|
29
32
|
throw new Error(`HTTP ${res.status}`);
|
|
33
|
+
}
|
|
30
34
|
return res.blob();
|
|
31
35
|
})
|
|
32
36
|
.then((blob) => createImageBitmap(blob, {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DefaultLoadingManager, Texture } from "three";
|
|
2
|
+
import threeLoader from "../threeLoader";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* ⭐ 甲(皇上旨 2026-10-05「你试试甲」):一张全景图(整图 / 单片瓦片,现行 32 片/全景)的**异步解码**建纹理。
|
|
@@ -27,7 +28,11 @@ export function loadPanoTexture(url: string): Promise<Texture> {
|
|
|
27
28
|
DefaultLoadingManager.itemStart(url);
|
|
28
29
|
const task = fetch(url)
|
|
29
30
|
.then((res) => {
|
|
30
|
-
|
|
31
|
+
// 状态码先记进 `threeLoader`(页面据此分「服务器明说没有」与「断网」:前者才敢删那条数据)
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
threeLoader.markUrlStatus(url, res.status);
|
|
34
|
+
throw new Error(`HTTP ${res.status}`);
|
|
35
|
+
}
|
|
31
36
|
return res.blob();
|
|
32
37
|
})
|
|
33
38
|
.then((blob) =>
|
package/package.json
CHANGED
package/threeLoader.d.ts
CHANGED
|
@@ -9,6 +9,20 @@ import { PLYLoader } from "three/examples/jsm/Addons";
|
|
|
9
9
|
import type { ThreeLoaderEventMap } from "./EventMap";
|
|
10
10
|
declare class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
|
|
11
11
|
constructor();
|
|
12
|
+
/** 已经报过的失败地址(去重只服务「计数」,不影响事件本身 —— 事件仍每次发,页面可按 `isNew` 决定要不要提示) */
|
|
13
|
+
private _failedUrls;
|
|
14
|
+
/** 全站累计**失败地址数**(与 manager 的 itemsTotal/itemsLoaded 同口径:累加、永不清零 ⇒ 页面自己算「本次」) */
|
|
15
|
+
loadErrorCount: number;
|
|
16
|
+
/** 地址 → 服务端回的状态码。只有**拿到过回复**的那两路写得进来:`loadPanoTexture` 的非 2xx(全景整图/32 片切片、平面 4 片)
|
|
17
|
+
* 与 `loadModel` 的下载失败(GLB 等)。用来把「服务器明说没有」和「断网 / 解码失败」分开 —— 前者页面才敢删数据。 */
|
|
18
|
+
private _urlStatus;
|
|
19
|
+
/** 记下一个地址的服务端状态码(键必须与失败上报用的那一个地址串一致) */
|
|
20
|
+
markUrlStatus(url: string, status: number): void;
|
|
21
|
+
/** 失败的唯一上报口:`DefaultLoadingManager.onError`(素材那一路)与 `ModelSlot` 那颗 GLB 没出来(模型那一路)都走这里。 */
|
|
22
|
+
reportLoadError(url: string, kind: "asset" | "model", label?: string): void;
|
|
23
|
+
/** 从加载器抛出的错误里取 HTTP 状态码,记在**调用方那一个地址**上(`url` 原样,不是内部补全后的地址)。
|
|
24
|
+
* three 的 `FileLoader` 非 2xx 抛 `HttpError`(`err.response.status`),个别加载器只把码写进 message。 */
|
|
25
|
+
private _noteErrStatus;
|
|
12
26
|
getFilename(url: string): string;
|
|
13
27
|
uuid: string;
|
|
14
28
|
anisotropy: number;
|
package/threeLoader.js
CHANGED
|
@@ -40,6 +40,12 @@ class ThreeLoader extends EventDispatcher {
|
|
|
40
40
|
DefaultLoadingManager.onLoad = () => {
|
|
41
41
|
this.onload = true;
|
|
42
42
|
};
|
|
43
|
+
// 资源加载失败:走 manager 计数的每一条(全景整图/32 片切片、图片平面 4 片、GLB 下载与内嵌贴图)
|
|
44
|
+
// 都只从这里出,页面侧仍只订本类这一个出口(与 onProgress 同规矩,⛔ 页面不许自己占 manager 的槽位)。
|
|
45
|
+
// 同一地址去重:全景/平面那两路失败会每帧重发请求 ⇒ 不去重「失败数」会被刷成无穷大。
|
|
46
|
+
DefaultLoadingManager.onError = (url) => {
|
|
47
|
+
this.reportLoadError(url, "asset");
|
|
48
|
+
};
|
|
43
49
|
// 资源替换
|
|
44
50
|
DefaultLoadingManager.setURLModifier((url) => {
|
|
45
51
|
//全路径匹配资源
|
|
@@ -58,6 +64,40 @@ class ThreeLoader extends EventDispatcher {
|
|
|
58
64
|
return new_url;
|
|
59
65
|
});
|
|
60
66
|
}
|
|
67
|
+
/** 已经报过的失败地址(去重只服务「计数」,不影响事件本身 —— 事件仍每次发,页面可按 `isNew` 决定要不要提示) */
|
|
68
|
+
_failedUrls = new Set();
|
|
69
|
+
/** 全站累计**失败地址数**(与 manager 的 itemsTotal/itemsLoaded 同口径:累加、永不清零 ⇒ 页面自己算「本次」) */
|
|
70
|
+
loadErrorCount = 0;
|
|
71
|
+
/** 地址 → 服务端回的状态码。只有**拿到过回复**的那两路写得进来:`loadPanoTexture` 的非 2xx(全景整图/32 片切片、平面 4 片)
|
|
72
|
+
* 与 `loadModel` 的下载失败(GLB 等)。用来把「服务器明说没有」和「断网 / 解码失败」分开 —— 前者页面才敢删数据。 */
|
|
73
|
+
_urlStatus = new Map();
|
|
74
|
+
/** 记下一个地址的服务端状态码(键必须与失败上报用的那一个地址串一致) */
|
|
75
|
+
markUrlStatus(url, status) {
|
|
76
|
+
if (url)
|
|
77
|
+
this._urlStatus.set(url, status);
|
|
78
|
+
}
|
|
79
|
+
/** 失败的唯一上报口:`DefaultLoadingManager.onError`(素材那一路)与 `ModelSlot` 那颗 GLB 没出来(模型那一路)都走这里。 */
|
|
80
|
+
reportLoadError(url, kind, label) {
|
|
81
|
+
const isNew = !this._failedUrls.has(url);
|
|
82
|
+
if (isNew) {
|
|
83
|
+
this._failedUrls.add(url);
|
|
84
|
+
this.loadErrorCount++;
|
|
85
|
+
}
|
|
86
|
+
const status = this._urlStatus.get(url);
|
|
87
|
+
// 403 与 404 并一档:OSS 私有桶里「key 不存在」回的正是 403(AccessDenied 用来不泄露对象是否存在)。
|
|
88
|
+
const missing = status === 404 || status === 403;
|
|
89
|
+
this.dispatchEvent({ type: "loadError", url, kind, label, status, missing, failed: this.loadErrorCount, isNew });
|
|
90
|
+
}
|
|
91
|
+
/** 从加载器抛出的错误里取 HTTP 状态码,记在**调用方那一个地址**上(`url` 原样,不是内部补全后的地址)。
|
|
92
|
+
* three 的 `FileLoader` 非 2xx 抛 `HttpError`(`err.response.status`),个别加载器只把码写进 message。 */
|
|
93
|
+
_noteErrStatus(url, err) {
|
|
94
|
+
if (!isString(url))
|
|
95
|
+
return;
|
|
96
|
+
const e = err;
|
|
97
|
+
const raw = e?.status ?? e?.response?.status ?? Number(/(?:responded with|HTTP) (\d{3})/.exec(e?.message ?? "")?.[1]);
|
|
98
|
+
if (Number.isFinite(raw))
|
|
99
|
+
this.markUrlStatus(url, raw);
|
|
100
|
+
}
|
|
61
101
|
// 从url 中提取文件名
|
|
62
102
|
getFilename(url) {
|
|
63
103
|
// 移除URL的查询字符串和哈希部分
|
|
@@ -364,7 +404,8 @@ class ThreeLoader extends EventDispatcher {
|
|
|
364
404
|
});
|
|
365
405
|
return res;
|
|
366
406
|
})
|
|
367
|
-
.catch(() => {
|
|
407
|
+
.catch((err) => {
|
|
408
|
+
this._noteErrStatus(url, err);
|
|
368
409
|
console.error(`${url},加载失败`);
|
|
369
410
|
return undefined;
|
|
370
411
|
});
|
|
@@ -381,6 +422,7 @@ class ThreeLoader extends EventDispatcher {
|
|
|
381
422
|
return res.scene;
|
|
382
423
|
})
|
|
383
424
|
.catch((err) => {
|
|
425
|
+
this._noteErrStatus(url, err);
|
|
384
426
|
// 打印详细错误信息,方便调试
|
|
385
427
|
console.error(`${url},加载失败`, err);
|
|
386
428
|
return undefined;
|
|
@@ -398,7 +440,8 @@ class ThreeLoader extends EventDispatcher {
|
|
|
398
440
|
roughness: 0.5,
|
|
399
441
|
}));
|
|
400
442
|
})
|
|
401
|
-
.catch(() => {
|
|
443
|
+
.catch((err) => {
|
|
444
|
+
this._noteErrStatus(url, err);
|
|
402
445
|
console.error(`${url},加载失败`);
|
|
403
446
|
return undefined;
|
|
404
447
|
});
|
|
@@ -434,7 +477,8 @@ class ThreeLoader extends EventDispatcher {
|
|
|
434
477
|
}
|
|
435
478
|
return plyMesh;
|
|
436
479
|
})
|
|
437
|
-
.catch(() => {
|
|
480
|
+
.catch((err) => {
|
|
481
|
+
this._noteErrStatus(url, err);
|
|
438
482
|
console.error(`${url},加载失败`);
|
|
439
483
|
return undefined;
|
|
440
484
|
});
|
|
@@ -462,6 +506,7 @@ class ThreeLoader extends EventDispatcher {
|
|
|
462
506
|
return res;
|
|
463
507
|
})
|
|
464
508
|
.catch((err) => {
|
|
509
|
+
this._noteErrStatus(url, err);
|
|
465
510
|
console.error(`${url},加载失败`, err);
|
|
466
511
|
return undefined;
|
|
467
512
|
});
|
package/threeLoader.ts
CHANGED
|
@@ -44,6 +44,12 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
|
|
|
44
44
|
DefaultLoadingManager.onLoad = () => {
|
|
45
45
|
this.onload = true;
|
|
46
46
|
};
|
|
47
|
+
// 资源加载失败:走 manager 计数的每一条(全景整图/32 片切片、图片平面 4 片、GLB 下载与内嵌贴图)
|
|
48
|
+
// 都只从这里出,页面侧仍只订本类这一个出口(与 onProgress 同规矩,⛔ 页面不许自己占 manager 的槽位)。
|
|
49
|
+
// 同一地址去重:全景/平面那两路失败会每帧重发请求 ⇒ 不去重「失败数」会被刷成无穷大。
|
|
50
|
+
DefaultLoadingManager.onError = (url: string) => {
|
|
51
|
+
this.reportLoadError(url, "asset");
|
|
52
|
+
};
|
|
47
53
|
// 资源替换
|
|
48
54
|
DefaultLoadingManager.setURLModifier((url: string) => {
|
|
49
55
|
//全路径匹配资源
|
|
@@ -62,6 +68,37 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
|
|
|
62
68
|
return new_url;
|
|
63
69
|
});
|
|
64
70
|
}
|
|
71
|
+
/** 已经报过的失败地址(去重只服务「计数」,不影响事件本身 —— 事件仍每次发,页面可按 `isNew` 决定要不要提示) */
|
|
72
|
+
private _failedUrls: Set<string> = new Set();
|
|
73
|
+
/** 全站累计**失败地址数**(与 manager 的 itemsTotal/itemsLoaded 同口径:累加、永不清零 ⇒ 页面自己算「本次」) */
|
|
74
|
+
loadErrorCount: number = 0;
|
|
75
|
+
/** 地址 → 服务端回的状态码。只有**拿到过回复**的那两路写得进来:`loadPanoTexture` 的非 2xx(全景整图/32 片切片、平面 4 片)
|
|
76
|
+
* 与 `loadModel` 的下载失败(GLB 等)。用来把「服务器明说没有」和「断网 / 解码失败」分开 —— 前者页面才敢删数据。 */
|
|
77
|
+
private _urlStatus: Map<string, number> = new Map();
|
|
78
|
+
/** 记下一个地址的服务端状态码(键必须与失败上报用的那一个地址串一致) */
|
|
79
|
+
markUrlStatus(url: string, status: number): void {
|
|
80
|
+
if (url) this._urlStatus.set(url, status);
|
|
81
|
+
}
|
|
82
|
+
/** 失败的唯一上报口:`DefaultLoadingManager.onError`(素材那一路)与 `ModelSlot` 那颗 GLB 没出来(模型那一路)都走这里。 */
|
|
83
|
+
reportLoadError(url: string, kind: "asset" | "model", label?: string): void {
|
|
84
|
+
const isNew = !this._failedUrls.has(url);
|
|
85
|
+
if (isNew) {
|
|
86
|
+
this._failedUrls.add(url);
|
|
87
|
+
this.loadErrorCount++;
|
|
88
|
+
}
|
|
89
|
+
const status = this._urlStatus.get(url);
|
|
90
|
+
// 403 与 404 并一档:OSS 私有桶里「key 不存在」回的正是 403(AccessDenied 用来不泄露对象是否存在)。
|
|
91
|
+
const missing = status === 404 || status === 403;
|
|
92
|
+
this.dispatchEvent({ type: "loadError", url, kind, label, status, missing, failed: this.loadErrorCount, isNew });
|
|
93
|
+
}
|
|
94
|
+
/** 从加载器抛出的错误里取 HTTP 状态码,记在**调用方那一个地址**上(`url` 原样,不是内部补全后的地址)。
|
|
95
|
+
* three 的 `FileLoader` 非 2xx 抛 `HttpError`(`err.response.status`),个别加载器只把码写进 message。 */
|
|
96
|
+
private _noteErrStatus(url: string | File | Array<File>, err: unknown): void {
|
|
97
|
+
if (!isString(url)) return;
|
|
98
|
+
const e = err as { status?: number; response?: { status?: number }; message?: string };
|
|
99
|
+
const raw = e?.status ?? e?.response?.status ?? Number(/(?:responded with|HTTP) (\d{3})/.exec(e?.message ?? "")?.[1]);
|
|
100
|
+
if (Number.isFinite(raw)) this.markUrlStatus(url, raw);
|
|
101
|
+
}
|
|
65
102
|
// 从url 中提取文件名
|
|
66
103
|
getFilename(url: string): string {
|
|
67
104
|
// 移除URL的查询字符串和哈希部分
|
|
@@ -370,7 +407,8 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
|
|
|
370
407
|
});
|
|
371
408
|
return res;
|
|
372
409
|
})
|
|
373
|
-
.catch(() => {
|
|
410
|
+
.catch((err) => {
|
|
411
|
+
this._noteErrStatus(url, err);
|
|
374
412
|
console.error(`${url},加载失败`);
|
|
375
413
|
return undefined;
|
|
376
414
|
});
|
|
@@ -387,6 +425,7 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
|
|
|
387
425
|
return res.scene;
|
|
388
426
|
})
|
|
389
427
|
.catch((err) => {
|
|
428
|
+
this._noteErrStatus(url, err);
|
|
390
429
|
// 打印详细错误信息,方便调试
|
|
391
430
|
console.error(`${url},加载失败`, err);
|
|
392
431
|
return undefined;
|
|
@@ -404,7 +443,8 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
|
|
|
404
443
|
roughness: 0.5,
|
|
405
444
|
}));
|
|
406
445
|
})
|
|
407
|
-
.catch(() => {
|
|
446
|
+
.catch((err) => {
|
|
447
|
+
this._noteErrStatus(url, err);
|
|
408
448
|
console.error(`${url},加载失败`);
|
|
409
449
|
return undefined;
|
|
410
450
|
});
|
|
@@ -440,7 +480,8 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
|
|
|
440
480
|
}
|
|
441
481
|
return plyMesh;
|
|
442
482
|
})
|
|
443
|
-
.catch(() => {
|
|
483
|
+
.catch((err) => {
|
|
484
|
+
this._noteErrStatus(url, err);
|
|
444
485
|
console.error(`${url},加载失败`);
|
|
445
486
|
return undefined;
|
|
446
487
|
});
|
|
@@ -467,6 +508,7 @@ class ThreeLoader extends EventDispatcher<ThreeLoaderEventMap> {
|
|
|
467
508
|
return res;
|
|
468
509
|
})
|
|
469
510
|
.catch((err) => {
|
|
511
|
+
this._noteErrStatus(url, err);
|
|
470
512
|
console.error(`${url},加载失败`, err);
|
|
471
513
|
return undefined;
|
|
472
514
|
});
|