From 00f37c7e3088fe022f0997fbae3ac158f99c6a2e Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Sun, 2 Aug 2026 05:28:33 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(games):=20=E7=AC=AC=E4=B8=80=E4=BA=BA?= =?UTF-8?q?=E7=A8=B1=E8=A6=96=E8=A7=92=E5=88=87=E6=8F=9B=EF=BC=88V?= =?UTF-8?q?=EF=BC=8F=F0=9F=91=81=EF=BC=89=E2=80=94=E2=80=94=E6=9C=80?= =?UTF-8?q?=E5=B0=8F=E5=8F=AF=E7=8E=A9=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主世界新增第一/第三人稱切換。之所以便宜,是因為既有架構剛好都站在對的那一邊: 移動早就是相機相對(camera.getWorldDirection 驅動 WASD,虛擬搖桿走同一條路徑), 互動全部是距離觸發(Math.hypot(hero.position…),唯一的 raycast 只打 terrain), 小地圖已經跟著 yaw 轉,碰撞是 XZ 剛體推出。所以這裡只換相機求值,不換操作系統。 (#11 曾移除過檔案室的第一人稱房間,原因是「室內 WASD 變平移、手機搖桿只能平移形同卡住」。 那是換一套操作;這次移動/互動/搖桿全部沿用主世界,只有相機不同。) 改了什麼 - 兩模式共用同一組 yaw/pitch:第一人稱視線 = 第三人稱「主角→相機」方位取負號。 方向語意一致(pitch 越大越往下看)→ 拖曳手感相同、切換時畫面不會上下顛倒。 可用範圍不同:第三人稱 [0.16, 1.2]、第一人稱 [-1.35, 1.0]。 - 視點:hero.position.y + 2.6(頭部中心),再往視線前方推 0.45。 不推的話相機正好架在自己軀幹(膠囊 r=0.55)上,稍微低頭就有一大塊橘色身體糊在畫面下緣。 0.45 是上限:HERO_R=0.6 讓身體停在牆前 0.6,相機推完離牆面還有 0.15 > near plane 0.1。 - 不 lerp、不夾地:相機長在身上,插值只會變拖曳感。走路的 0.12 踏步起伏直接變成免費的鏡頭晃動。 - 只藏頭(head.visible):髮與眼是 head 的子物件會一起消失。不整個 hero 隱藏—— three.js 中 visible=false 就不投影,白天主角影子會憑空不見。 - 身體恆跟著視線(hero.rotation.y = yaw + π):火把與地面光錐吃的是 hero.rotation.y, 若沿用「身體朝移動方向」,夜裡按 A 側移光束就會憑空甩到旁邊。 - FOV 52° → 68°:第三人稱的 52° 用在第一人稱像望遠鏡。 - 第一人稱關閉點地移動與滾輪縮放(dist 保留 → 切回去距離不變);主角對話泡隱藏 (掛在頭頂 y=3.6=相機正上方 1m,CSS2D 投影會跑掉)。 - 戰鬥分支補 head.visible = true:battle 自己接管相機且提早 return 跳過鏡頭段, 否則第一人稱切進戰鬥會是無頭主角。終局運鏡同理走第三人稱。 - UI:右上角 👁 按鈕(進行中反白)+ V 鍵;V 未與既有鍵位衝突。 驗證(headless Chrome + swiftshader,CDP 驅動真實 rAF,非虛擬時間) - 俯仰夾制走真實 pointer 事件:第一人稱夾在 -1.350 / 1.000,第三人稱仍是 0.160 / 1.2 - 切換往返:fov 68↔52、頭 false↔true、aria-pressed 同步,V 鍵與按鈕皆可 - 夜晚側移(A)時火把光錐仍鎖在視線正前方 → 身體跟視線的修正生效 - 白天村莊、高草區、極限俯仰(純天空/無破面)皆截圖確認 已知、刻意未處理 - 走到市集棚正前方時相機會鑽進遮陽帆布:帆布在 y=2.25–2.70、視點 2.6, 碰撞盒半深 1.0 讓身體停在 1.6、相機前推後在 1.15 < 帆布邊緣 1.35。 修法是把 addBoxCol 的半深加大,但那會連帶改變第三人稱「能走多靠近攤位」→ 留待決定。 - 最大俯角(1.0)畫面最下緣仍有一小條自己的軀幹。 - 切換為硬切,尚未做 dist 24→0 的補間。 - 按鈕 title 目前只有正體(與既有 shotbtn 一致),尚未進 i18n。 Signed-off-by: Toomore Chiang Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RzFUxR8R7kW8jLNAgWcTf7 Signed-off-by: Toomore Chiang --- docs/games/index.html | 6 ++-- docs/games/main.js | 69 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/docs/games/index.html b/docs/games/index.html index 2dc2a39..2835459 100644 --- a/docs/games/index.html +++ b/docs/games/index.html @@ -105,9 +105,10 @@ /* 任務按鈕 + 清單 */ #topright { position:fixed; right:16px; top:16px; z-index:12; display:flex; gap:8px; align-items:flex-start; } - #logbtn, #soundbtn, #achbtn, #shotbtn, #explorebadge { cursor:pointer; border:0; font:inherit; font-size:13px; font-weight:700; color:var(--ink); + #logbtn, #soundbtn, #achbtn, #shotbtn, #fpvbtn, #explorebadge { cursor:pointer; border:0; font:inherit; font-size:13px; font-weight:700; color:var(--ink); background:rgba(251,247,239,.93); padding:9px 14px; border-radius:999px; box-shadow:var(--shadow); } - #soundbtn, #shotbtn { padding:9px 12px; } + #soundbtn, #shotbtn, #fpvbtn { padding:9px 12px; } + #fpvbtn.on { color:#fff; background:rgba(58,86,122,.95); } /* 第一人稱進行中:反白,避免只靠圖示分不出目前模式 */ #explorebadge { color:#8a6d1f; background:rgba(255,224,130,.95); } /* 探索模式標籤:日光金;點一下回到故事模式 */ /* 語言切換器(由 main.js 動態插入,少於 2 種語言時不顯示) */ #langsw { display:flex; gap:3px; background:rgba(251,247,239,.93); padding:4px; border-radius:999px; box-shadow:var(--shadow); } @@ -577,6 +578,7 @@

資安防護新手村 · 探險

+ diff --git a/docs/games/main.js b/docs/games/main.js index 81f1ffc..d54752b 100644 --- a/docs/games/main.js +++ b/docs/games/main.js @@ -2294,6 +2294,20 @@ const keys = new Set(); addEventListener('keydown', (e) => { if (e.code === 'Space') e.preventDefault(); keys.add(e.code); bumpActive(); }); addEventListener('keyup', (e) => { keys.delete(e.code); }); let yaw = Math.PI, pitch = 0.6, dist = 24; +// ── 第一/第三人稱:兩模式共用同一組 yaw/pitch ───────────────────── +// pitch 在第三人稱是「相機比主角高多少」、在第一人稱是「視線俯角」,但方向一致(越大越往下看), +// 所以兩邊拖曳手感相同、切換時畫面不會上下顛倒;差別只在可用範圍:第三人稱不能鑽到地面下,第一人稱要能抬頭看天。 +let fpv = false; +const FP_EYE = 2.6; // 視點高度=頭部中心(hero 局部 y);主角約 3m 高 +// 視點往視線前方推 0.45:相機原本剛好架在自己軀幹(膠囊 r=0.55)的正上方,稍微低頭就有一大塊橘色身體糊在畫面下緣。 +// 推到身體之前就看不到了,身體本身仍留著投影(低頭雖看不到腳,但陽光下自己的影子還在)。 +// 上限受碰撞半徑約束:HERO_R=0.6 讓身體停在牆前 0.6,相機推 0.45 後離牆面還有 0.15 > near plane 0.1 → 貼牆也不會穿進室內。 +const FP_FWD = 0.45; +const FP_FOV = 68, TP_FOV = camera.fov; // 第三人稱 52° 用在第一人稱會像望遠鏡;FPS 慣例 65–75° +// 抬頭到 +1.35(77°,留餘裕避開 lookAt 在正上方的 up 向量退化),低頭只到 1.0(57°): +// 相機往前推的 0.45 小於軀幹半徑 0.55,再往下看就會拍到自己那塊沒有細節的膠囊——1.0 已足夠看清腳前的地面。 +const FP_PITCH_UP = 1.35, FP_PITCH_DN = 1.0; +const clampPitch = (p) => (fpv ? THREE.MathUtils.clamp(p, -FP_PITCH_UP, FP_PITCH_DN) : THREE.MathUtils.clamp(p, 0.16, 1.2)); // 開場就把鏡頭擺到跟隨位置,避免從原點 (0,0,0) 起始=卡在中央水晶光束裡(遺跡全通後光束很亮會洗版) { const sy = groundY(hero.position.x, hero.position.z); camera.position.set(hero.position.x + Math.sin(yaw) * Math.cos(pitch) * dist, sy + Math.sin(pitch) * dist + 2, hero.position.z + Math.cos(yaw) * Math.cos(pitch) * dist); } const moveTarget = new THREE.Vector3(); let hasTarget = false; @@ -2305,11 +2319,11 @@ dom.addEventListener('pointermove', (e) => { if (!pDown) return; const dx = e.clientX - lastX, dy = e.clientY - lastY; if (!dragging && Math.hypot(e.clientX - downX, e.clientY - downY) > 6) dragging = true; - if (dragging) { yaw -= dx * 0.005; pitch = THREE.MathUtils.clamp(pitch - dy * 0.004, 0.16, 1.2); bumpActive(); } + if (dragging) { yaw -= dx * 0.005; pitch = clampPitch(pitch - dy * 0.004); bumpActive(); } lastX = e.clientX; lastY = e.clientY; }); dom.addEventListener('pointerup', (e) => { - if (pDown && !dragging) { + if (pDown && !dragging && !fpv) { // 第一人稱不做點地移動:盯著地面點一下然後自己走過去,與「視線=面向」的語意衝突(手機仍有搖桿) ndc.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1); raycaster.setFromCamera(ndc, camera); const hit = raycaster.intersectObject(terrain, false)[0]; @@ -2322,7 +2336,20 @@ dom.addEventListener('pointerup', (e) => { } pDown = false; dragging = false; }); -addEventListener('wheel', (e) => { dist = THREE.MathUtils.clamp(dist + e.deltaY * 0.025, 14, 56); bumpActive(); }, { passive: true }); +addEventListener('wheel', (e) => { if (fpv) return; dist = THREE.MathUtils.clamp(dist + e.deltaY * 0.025, 14, 56); bumpActive(); }, { passive: true }); +// 視角切換:V 鍵或右上角 👁。dist 保持不動 → 切回第三人稱時鏡頭距離跟切走前一樣。 +const fpvBtn = document.getElementById('fpvbtn'); +function setFPV(on) { + if (on === fpv) return; + fpv = on; + pitch = clampPitch(pitch); // 第一人稱→第三人稱時把抬頭的視角壓回地面以上 + camera.fov = fpv ? FP_FOV : TP_FOV; camera.updateProjectionMatrix(); + heroBubble.visible = !fpv; // 泡泡掛在頭頂 y=3.6=第一人稱相機正上方 1m,CSS2D 投影會跑到畫面外 + hasTarget = false; // 取消進行中的點地移動 + fpvBtn.classList.toggle('on', fpv); fpvBtn.setAttribute('aria-pressed', fpv ? 'true' : 'false'); + bumpActive(); +} +fpvBtn.addEventListener('click', () => setFPV(!fpv)); // 首次互動解鎖音訊(瀏覽器自動播放限制;iOS 需多種手勢) ['pointerdown', 'touchend', 'click', 'keydown'].forEach((ev) => addEventListener(ev, () => SFX.unlock(), { passive: true })); document.addEventListener('visibilitychange', () => { if (!document.hidden) SFX.unlock(); }); @@ -3261,6 +3288,7 @@ const torchParams = [ let torchSel = 0; addEventListener('keydown', (e) => { if (document.activeElement && document.activeElement.tagName === 'INPUT') return; + if (e.code === 'KeyV') { setFPV(!fpv); return; } if (e.code === 'KeyG') { coordsEl.classList.toggle('show'); return; } if (!coordsEl.classList.contains('show')) return; if (e.code === 'BracketLeft') torchSel = (torchSel + torchParams.length - 1) % torchParams.length; @@ -3316,7 +3344,7 @@ function animate(time) { if (egg.active) { labelRenderer.domElement.style.display = 'none'; egg.update(dt); return; } // 彩蛋小遊戲:凍結 3D 世界,僅跑 2D 覆蓋層(不透明、免 renderScene) if (fishing.active) { labelRenderer.domElement.style.display = 'none'; fishing.update(dt); return; } // 釣魚小遊戲:同彩蛋,凍結 3D 世界 if (stations.active) { labelRenderer.domElement.style.display = 'none'; stations.update(dt); return; } // 支線站台:同上,凍結 3D 世界,僅跑 DOM 覆蓋層 - if (battle.active) { labelRenderer.domElement.style.display = 'none'; gradePass.uniforms.uVibrancy.value = 1; setWorldLight(1); battle.update(dt); renderScene(); return; } + if (battle.active) { labelRenderer.domElement.style.display = 'none'; head.visible = true; gradePass.uniforms.uVibrancy.value = 1; setWorldLight(1); battle.update(dt); renderScene(); return; } // 戰鬥自己接管相機(第三人稱運鏡)且提早 return 跳過下面的鏡頭段 → 頭要在這裡裝回去,否則第一人稱切進戰鬥會是無頭主角 labelRenderer.domElement.style.display = finaleActive ? 'none' : ''; camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize(); @@ -3349,9 +3377,12 @@ function animate(time) { if (len > worldLim()) hero.position.multiplyScalar(worldLim() / len); resolveHeroCollision(); // 剛體碰撞:把玩家推出重疊的實體(撞牆停住、沿牆滑) const tr = Math.atan2(moveDir.x, moveDir.z); - if (!spinning) hero.rotation.y += ((((tr - hero.rotation.y) % TAU) + Math.PI * 3) % TAU - Math.PI) * Math.min(1, 12 * dt); + if (!spinning && !fpv) hero.rotation.y += ((((tr - hero.rotation.y) % TAU) + Math.PI * 3) % TAU - Math.PI) * Math.min(1, 12 * dt); moving = true; } + // 第一人稱:身體恆跟著視線(不是跟著移動方向)。否則按 A 側移時身體會轉開,而火把與地面光錐是吃 hero.rotation.y 的 → 夜裡光束會憑空甩到旁邊。 + // 相機視線水平方向是 (-sin yaw, -cos yaw)、主角正面是 (sin ry, cos ry) → ry = yaw + π。二段跳轉圈時讓位給 spin。 + if (fpv && !spinning && !finaleActive) hero.rotation.y = yaw + Math.PI; // 跳躍:空白鍵或手機跳躍鍵;在地面起跳,空中再按一次=二段跳並轉一圈;終局運鏡時禁用 const grounded = jumpOff <= 0; @@ -3409,14 +3440,28 @@ function animate(time) { } wasInWater = inWater; - // 鏡頭:第三人稱(避免穿地)——檔案室已改原地面板,不再有第一人稱模式 + // 鏡頭:第一/第三人稱(V 或右上角 👁 切換)。終局運鏡自己接管相機 → 那時一律走第三人稱、把頭裝回去 { - camPos.set(hero.position.x + Math.sin(yaw) * Math.cos(pitch) * dist, hero.position.y + Math.sin(pitch) * dist + 2, hero.position.z + Math.cos(yaw) * Math.cos(pitch) * dist); - const camGround = terrainHeight(camPos.x, camPos.z) + 3; - if (camPos.y < camGround) camPos.y = camGround; - camera.position.lerp(camPos, 1 - Math.exp(-6 * dt)); - if (camera.position.distanceToSquared(camPos) < 1e-4) camera.position.copy(camPos); // 夠近就吸附=相機完全停住;否則指數逼近永遠到不了,靜止後相機仍每幀次像素微動 → 水岸線等高對比邊緣持續閃爍(看起來像水面在抖) - lookAt.set(hero.position.x, hero.position.y + 2.4, hero.position.z); camera.lookAt(lookAt); + const inFP = fpv && !finaleActive; + // 只藏頭:髮與眼是 head 的子物件會一起消失。不整個 hero 隱藏——three.js 中 visible=false 就不投影,白天主角影子會憑空不見。 + // 留著身體還能低頭看到自己,沉浸感更好;眼球在頭前 0.45m、near plane 才 0.1,不藏頭會有兩顆黑點糊在畫面中央。 + head.visible = !inFP; + if (inFP) { + // 不 lerp、不夾地:相機本來就長在身上,插值只會變成拖曳感,而視點恆在地面之上。 + // 走路時 hero.position.y 已有 0.12 的踏步起伏 → 第一人稱的鏡頭晃動是免費附送的。 + const fx = -Math.sin(yaw), fz = -Math.cos(yaw); // 視線的水平方向(與 hero.rotation.y = yaw + π 同向) + camera.position.set(hero.position.x + fx * FP_FWD, hero.position.y + FP_EYE, hero.position.z + fz * FP_FWD); + const cp = Math.cos(pitch); // 視線=第三人稱「主角→相機」方位取負號,所以兩模式的 yaw/pitch 可以直接共用 + lookAt.set(camera.position.x + fx * cp, camera.position.y - Math.sin(pitch), camera.position.z + fz * cp); + camera.lookAt(lookAt); + } else { + camPos.set(hero.position.x + Math.sin(yaw) * Math.cos(pitch) * dist, hero.position.y + Math.sin(pitch) * dist + 2, hero.position.z + Math.cos(yaw) * Math.cos(pitch) * dist); + const camGround = terrainHeight(camPos.x, camPos.z) + 3; + if (camPos.y < camGround) camPos.y = camGround; + camera.position.lerp(camPos, 1 - Math.exp(-6 * dt)); + if (camera.position.distanceToSquared(camPos) < 1e-4) camera.position.copy(camPos); // 夠近就吸附=相機完全停住;否則指數逼近永遠到不了,靜止後相機仍每幀次像素微動 → 水岸線等高對比邊緣持續閃爍(看起來像水面在抖) + lookAt.set(hero.position.x, hero.position.y + 2.4, hero.position.z); camera.lookAt(lookAt); + } } // 終局運鏡:鏡頭飛向村莊中央大水晶,看完噴發動畫後才彈出完成畫面 if (finaleActive) { From 80137887563b7b486d12126c9e886cbc1a1fbc76 Mon Sep 17 00:00:00 2001 From: Toomore Chiang Date: Sun, 2 Aug 2026 06:16:03 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(games):=20=E7=AC=AC=E4=B8=80=E4=BA=BA?= =?UTF-8?q?=E7=A8=B1=E7=99=BD=E5=A4=A9=E9=81=8E=E6=9B=9D=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=A4=A9=E7=A9=BA=E9=80=B2=20bloom=20=E5=89=8D=E5=8A=A0?= =?UTF-8?q?=E8=BB=9F=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回報:第一人稱的白天「超級亮」。量測後是 bloom 吃到未經 tone mapping 的天空輻射值。 成因 Sky addon 輸出的是物理輻射值(白天實測 12–25),它自己的 在離屏 render target 上是 no-op——three.js 對 render target 一律關 tone mapping,留給最後的 OutputPass。 於是整片天空以 12–25 的量進入 UnrealBloomPass(threshold 才 1.05),再被 radius 0.4 的大範圍糊開。 第三人稱俯視,畫面裡幾乎沒有原始天空,糊下來影響有限;第一人稱地平線落在畫面中央, 天空的 bloom 直接蓋住整個下半部。實測第一人稱地面過曝面積 33.6%、地面平均亮度 0.831。 作法 在天空自己的 shader 收斂:取最亮通道等比縮放(不偏色),knee=1.0 以下原封不動 (雲、日出日落層次全留著),之上漸近逼近 SKY_CEIL=1.8。 只動天空材質 → 水晶/火把/法師球等 emissive 的 bloom 結構上完全不經過這段。 (不走選擇性 bloom 雙 composer:已知會在部分裝置讓發光物消失,見 main.js 既有註解。) 量測(headless Chrome + swiftshader,天氣鎖定為晴、曝光 1.040、vibrancy 1.00 全程一致, 場景暖機 16s 等開場運鏡與群山沉降結束後才取樣,每組都做還原可逆驗證) 地面平均 地面過曝 天空平均 天空過曝 第一人稱 修正前 0.831 33.6% 0.957 92.5% 第一人稱 ceil3.2 0.721 3.4% 0.944 88.6% 第一人稱 ceil1.8 0.643 0.1% 0.915 68.6% ← 採用 第一人稱 還原 0.836 36.5% 0.956 92.0% ← 可逆,確認差異來自本改動 第三人稱 修正前 0.450 0.2% 0.731 24.9% 第三人稱 ceil1.8 0.416 0.1% 0.631 19.0% 第三人稱 還原 0.413 0.1% 0.616 18.8% ← 三者同在雜訊帶內=第三人稱不受影響 夜晚(vibrancy 0.00)發光物不受影響: 火把 亮部P99.5 0.9211 / 0.9211,>0.75 面積 2.13% / 2.14%(修正後 / 修正前) 水晶 亮部P99.5 0.9882 / 0.9863,>0.75 面積 63.95% / 63.34% 直視太陽(仰角 24°)也一併確認:修正前是一片純白虛空,修正後可見藍天漸層與地平線的樹。 量測方法上的一個坑(記下來免得再踩) 前三輪比較全部無效,因為沒有控制兩個變因: 1. 天氣系統會把 toneMappingExposure 壓低 45%(雷雨),隨機發生 → 同設定兩次量到 0.409 與 0.690。 2. 開場運鏡與群山沉降會讓畫面亮度在前 12 秒自己從 0.683 掉到 0.398,與本改動無關。 跑一組「完全不改參數、只推進時間」的對照組才發現。 所以最終量測都先暖機 16s、鎖定天氣,並在每組後把參數還原以確認差異可逆。 Signed-off-by: Toomore Chiang Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RzFUxR8R7kW8jLNAgWcTf7 Signed-off-by: Toomore Chiang --- docs/games/main.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/games/main.js b/docs/games/main.js index d54752b..0689c0d 100644 --- a/docs/games/main.js +++ b/docs/games/main.js @@ -232,11 +232,25 @@ const WATER_NIGHT = new THREE.Color(0x1f3a52), WATER_DAY = new THREE.Color(0x4f9 // 桌機:大氣散射天空(Sky addon)+夜空星點;手機:維持原漸層天空球(零變動) let atmoSky = null, stars = null; +const SKY_KNEE = 1.0, SKY_CEIL = 1.8; // 天空進 bloom 前的軟上限(見下方說明);1.8/2.5/3.2/5.0 都量過,1.8 是地面完全不過曝的最高值 const _skySun = new THREE.Vector3(); if (Q.richSky) { atmoSky = new Sky(); atmoSky.scale.setScalar(1200); // 控制在鏡頭 far(1200) 內、又比場景大 → 永遠當背景 const su = atmoSky.material.uniforms; su.turbidity.value = 6; su.rayleigh.value = 2.2; su.mieCoefficient.value = 0.005; su.mieDirectionalG.value = 0.8; + // 天空亮度軟上限。Sky 輸出的是物理輻射值(白天實測 12–25),而它的 在離屏 + // render target 上是 no-op(three.js 對 render target 一律關 tone mapping,留給最後的 OutputPass)→ + // 整片天空以 12–25 的量進入 bloom(threshold 才 1.05),再被 radius 0.4 的大範圍糊開。 + // 第三人稱天空只佔畫面上緣,糊下來還在可接受範圍;第一人稱地平線落在畫面中央,就把整個下半部洗白。 + // 這裡在天空自己的 shader 收斂:SKY_KNEE 以下原封不動(雲、日出日落的層次全留著),之上漸近逼近 SKY_CEIL。 + // 只動天空 → 水晶/火把/法師球等 emissive 的 bloom 完全不受影響(選擇性 bloom 雙 composer 已知會在部分裝置讓發光物消失,不走那條)。 + su.uSkyCeil = { value: SKY_CEIL }; + atmoSky.material.fragmentShader = 'uniform float uSkyCeil;\n' + atmoSky.material.fragmentShader.replace( + 'gl_FragColor = vec4( texColor, 1.0 );', + `{ float m = max(max(texColor.r, texColor.g), texColor.b); // 取最亮通道 → 等比縮放、不偏色 + if (m > ${SKY_KNEE.toFixed(1)}) texColor *= (${SKY_KNEE.toFixed(1)} + (m - ${SKY_KNEE.toFixed(1)}) / (1.0 + (m - ${SKY_KNEE.toFixed(1)}) / max(1e-3, uSkyCeil - ${SKY_KNEE.toFixed(1)}))) / m; } + gl_FragColor = vec4( texColor, 1.0 );`); + atmoSky.material.needsUpdate = true; if ('cloudCoverage' in su) { su.cloudCoverage.value = 0.35; su.cloudDensity.value = 0.35; su.cloudSpeed.value = 0.00006; } // r184 內建微雲 scene.add(atmoSky); // 夜空星點:上半球隨機分布,opacity 由 (1-vibrancy) 驅動 → 夜晚顯現、白天淡出