open-sea-skin-dsh 2.0.6 → 2.0.7

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/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ # 2.0.7
2
+
3
+ ## Added — 「性能」分组:帧预算的四个开关
4
+
5
+ - 新增分组「性能」(在「毛玻璃」与「高级」之间):帧调度器、遮挡暂停、WebGL2 自适应;「高级」里追加窗口缩放防抖与毛玻璃刷新方式。
6
+ - 帧调度器把逻辑从渲染节拍里拆出来:帧率上限低于屏幕刷新率时,不再每帧空转星空、船队和相机。
7
+ - 遮挡暂停在窗口被盖住 / 切到后台时停掉渲染循环(或降到 10 fps),回到前台立即续上,时钟不跳。
8
+ - WebGL2 回退不再硬编码 1.0 像素比 / 30 fps / 0.6 比例,改用与画质档一致的自适应,并在降档后锁 30 秒防抖。
9
+ - 窗口缩放防抖 150ms:拖动窗口边缘时先拉伸旧画面,停手后再按新尺寸重建,避免连串重建。
10
+ - 毛玻璃刷新方式:实时 / 静态(保留底色、停掉每帧取样)/ 关闭。
11
+
12
+ ## Changed — 渲染比例改为探针升档
13
+
14
+ - 旧判据 `fps > TARGET_FPS + 6` 在帧率上限附近永远不可达(读数被上限钳住),比例呈现「只会降不会升」的棘轮。
15
+ - 现在每个窗口向上探 3%,用下一个窗口裁决;保不住的退回比例并把冷却翻倍(4 → 8 → 16 → 32 秒)。
16
+ - 保住的步进后静默 24 秒再做下一次,且不追最后 1.5% 的余量——每次裁决都要重分配绘制缓冲,那一下就是一帧闪烁,间隔太短会被读成卡顿。
17
+ - 切换画质档会清空在飞的探针。
18
+
1
19
  # 2.0.6
2
20
 
3
21
  ## Fixed — 浏览器模块 id 改为 npm 包名
package/README.en.md CHANGED
@@ -1,8 +1,8 @@
1
- # Open Sea Skin · Lab (2.0.4)
1
+ # Open Sea Skin · Lab (2.0.7)
2
2
 
3
3
  **A living ocean skin** for DeepSeek Harness Desktop and Web: waves that follow your session load, a sky driven by real ephemeris data, a star field built from a real catalogue, and boats that are your running subagents.
4
4
 
5
- ![version](https://img.shields.io/badge/version-2.0.4-138b8b) ![license](https://img.shields.io/badge/license-MIT-138b8b) ![upstream](https://img.shields.io/badge/upstream-d--dev0101%2Fopen--sea--skin-6c7f8a)
5
+ ![version](https://img.shields.io/badge/version-2.0.7-138b8b) ![license](https://img.shields.io/badge/license-MIT-138b8b) ![upstream](https://img.shields.io/badge/upstream-d--dev0101%2Fopen--sea--skin-6c7f8a)
6
6
 
7
7
  ---
8
8
 
@@ -10,12 +10,12 @@
10
10
 
11
11
  This project is a **rebuilt fork (Lab branch)** of **[d-dev0101/open-sea-skin](https://github.com/d-dev0101/open-sea-skin)** (author **d-dev0101**, MIT licence) at version **1.2.7**. It is **not** an official upstream release and is not maintained by upstream.
12
12
 
13
- It keeps upstream's rendering foundation (three.js WebGPU + TSL ocean, sky, clouds, glass approach) and rebuilds the wave driver, astronomical sky, star field, boat fleet and the whole settings panel on top of it, up to **2.0.4**. All current code, shaders, panel and tests are maintained here — please file issues against **this** repository, not upstream.
13
+ It keeps upstream's rendering foundation (three.js WebGPU + TSL ocean, sky, clouds, glass approach) and rebuilds the wave driver, astronomical sky, star field, boat fleet and the whole settings panel on top of it, up to **2.0.7**. All current code, shaders, panel and tests are maintained here — please file issues against **this** repository, not upstream.
14
14
 
15
15
  - Upstream: [d-dev0101/open-sea-skin](https://github.com/d-dev0101/open-sea-skin) · fork point `v1.2.7`
16
16
  - Upstream licence: MIT, full text in [`LICENSE`](LICENSE); required third-party notices in [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)
17
17
  - Why the fork exists and how to fall back: [`docs/FORK.md`](docs/FORK.md)
18
- - Current version: **2.0.4** (tag `v2.0.4`). 2.0.0 stays the frozen baseline and its tag still resolves to the source; the 2.0.0–2.0.3 releases are off the shelf, so only the newest installable package is published
18
+ - Current version: **2.0.7** (tag `v2.0.7`). 2.0.0 stays the frozen baseline and its tag still resolves to the source; the 2.0.0–2.0.3 releases are off the shelf, so only the newest installable package is published
19
19
 
20
20
  ---
21
21
 
@@ -58,12 +58,6 @@ Or install straight from GitHub (builds from this source tree):
58
58
  dsh plugin add github:BeckChiang/open-sea-skin
59
59
  ```
60
60
 
61
- To skip the source build on your machine, use the prebuilt tarball from the Release (no `allowBuilds` approval needed):
62
-
63
- ```powershell
64
- dsh plugin add https://github.com/BeckChiang/open-sea-skin/releases/download/v2.0.4/open-sea-skin-2.0.4.tgz
65
- ```
66
-
67
61
  ### Option 2: local link (for development)
68
62
 
69
63
  1. Clone into the DSH plugin directory:
@@ -83,8 +77,8 @@ dsh plugin add https://github.com/BeckChiang/open-sea-skin/releases/download/v2.
83
77
  **Verify**: the console should print
84
78
 
85
79
  ```text
86
- [open-sea-skin] client lab-2.0.4
87
- [open-sea-skin] ocean lab-2.0.4
80
+ [open-sea-skin] client lab-2.0.7
81
+ [open-sea-skin] ocean lab-2.0.7
88
82
  ```
89
83
 
90
84
  ---
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
- # Open Sea Skin · Lab(2.0.4)
1
+ # Open Sea Skin · Lab(2.0.7)
2
2
 
3
3
  **实时海洋皮肤** —— 给 DeepSeek Harness 桌面版 / Web 用的一层会呼吸的海面:浪跟着你的会话负载起伏,天光按真实星历走,星空是真的星表,船是你正在跑的子智能体。
4
4
 
5
- ![version](https://img.shields.io/badge/version-2.0.4-138b8b) ![license](https://img.shields.io/badge/license-MIT-138b8b) ![upstream](https://img.shields.io/badge/upstream-d--dev0101%2Fopen--sea--skin-6c7f8a)
5
+ ![version](https://img.shields.io/badge/version-2.0.7-138b8b) ![license](https://img.shields.io/badge/license-MIT-138b8b) ![upstream](https://img.shields.io/badge/upstream-d--dev0101%2Fopen--sea--skin-6c7f8a)
6
6
 
7
7
  ---
8
8
 
@@ -10,12 +10,12 @@
10
10
 
11
11
  本项目是基于 **[d-dev0101/open-sea-skin](https://github.com/d-dev0101/open-sea-skin)**(作者 **d-dev0101**,MIT 许可)**1.2.7** 版本**重新构建的 Lab 分支(fork)**,**不是上游官方版本**,也不由上游维护。
12
12
 
13
- 它沿用了上游的渲染底座(three.js WebGPU + TSL 的海面、天空、云与毛玻璃思路),在底座之上重做了海浪驱动、天文日照、星空、船队与整块设置面板,演进到 **2.0.4**。原生代码、着色器、面板与测试都由本仓库维护,出现的问题请提到**本仓库**而不是上游。
13
+ 它沿用了上游的渲染底座(three.js WebGPU + TSL 的海面、天空、云与毛玻璃思路),在底座之上重做了海浪驱动、天文日照、星空、船队与整块设置面板,演进到 **2.0.7**。原生代码、着色器、面板与测试都由本仓库维护,出现的问题请提到**本仓库**而不是上游。
14
14
 
15
15
  - 上游项目:[d-dev0101/open-sea-skin](https://github.com/d-dev0101/open-sea-skin) · 分叉起点:`v1.2.7`
16
16
  - 上游许可:MIT,全文见 [`LICENSE`](LICENSE);必须保留的第三方署名见 [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md)
17
17
  - 为什么分叉、改了什么、怎么回退上游:[`docs/FORK.md`](docs/FORK.md)
18
- - 当前版本:**2.0.4**(tag `v2.0.4`)。2.0.0 是冻结基线,tag 仍可检出源码;2.0.0–2.0.3 的发布包与 Release 已下架,只保留最新一版的可安装包
18
+ - 当前版本:**2.0.7**(tag `v2.0.7`)。2.0.0 是冻结基线,tag 仍可检出源码;2.0.0–2.0.3 的发布包与 Release 已下架,只保留最新一版的可安装包
19
19
 
20
20
  ---
21
21
 
@@ -62,12 +62,6 @@ dsh plugin add open-sea-skin-dsh
62
62
  dsh plugin add github:BeckChiang/open-sea-skin
63
63
  ```
64
64
 
65
- 不想让本机走源码构建的话,用 Release 上的预构建包(免 `allowBuilds` 授权):
66
-
67
- ```powershell
68
- dsh plugin add https://github.com/BeckChiang/open-sea-skin/releases/download/v2.0.4/open-sea-skin-2.0.4.tgz
69
- ```
70
-
71
65
  ### 方式二:本地 link(改代码用)
72
66
 
73
67
  1. 把仓库放到 DSH 插件目录:
@@ -87,8 +81,8 @@ dsh plugin add https://github.com/BeckChiang/open-sea-skin/releases/download/v2.
87
81
  **确认生效**:控制台第一行会出现
88
82
 
89
83
  ```text
90
- [open-sea-skin] client lab-2.0.4
91
- [open-sea-skin] ocean lab-2.0.4
84
+ [open-sea-skin] client lab-2.0.7
85
+ [open-sea-skin] ocean lab-2.0.7
92
86
  ```
93
87
 
94
88
  ---
package/README.zh.md CHANGED
@@ -1,11 +1,11 @@
1
- # Open Sea Skin · Lab 分支说明(2.0.4)
1
+ # Open Sea Skin · Lab 分支说明(2.0.7)
2
2
 
3
3
  这是 [open-sea-skin](https://github.com/d-dev0101/open-sea-skin)(MIT,作者 d-dev0101)1.2.7 的**重新构建版(fork)**,装在 DSH 桌面版的 `desktop` profile 里。首页与安装说明见 [`README.md`](README.md),本文是该版本的技术说明。
4
4
 
5
5
  上游只提供「海面 + 夕阳 + 玻璃界面」;本分支在它的地基上重做了五块:**负载驱动的波浪**、**真实天文的天色与星空**、**跟着会话变化的船队**、**可逐项取舍的性能开关**、**可折叠且带解释的设置面板**。
6
6
 
7
7
  - 上游版本:1.2.7
8
- - 本分支版本:**2.0.4**(2.0.0 是冻结基线;本文档是该版本的完整说明。2.0.0–2.0.3 的发布包已下架)
8
+ - 本分支版本:**2.0.7**(2.0.0 是冻结基线;本文档是该版本的完整说明。2.0.0–2.0.3 的发布包已下架)
9
9
  - 许可:MIT(见 `LICENSE`);上游必须保留的署名见 `THIRD_PARTY_NOTICES.md`
10
10
  - 开发位置:`C:\Users\<你>\DSH\plugins\open-sea-skin-lab`,profile 用 `link:` 安装
11
11
  - 默认档:**画质「省电」(0.85× 像素比)+ 30 fps 上限**,夜间核显占用多数时候低于 20%
@@ -44,7 +44,7 @@
44
44
  ```
45
45
  3. 重启桌面端或按 F5 刷新窗口。
46
46
 
47
- **确认生效**:控制台第一行有 `[open-sea-skin] client lab-2.0.4` 与 `[open-sea-skin] ocean lab-2.0.4`。
47
+ **确认生效**:控制台第一行有 `[open-sea-skin] client lab-2.0.7` 与 `[open-sea-skin] ocean lab-2.0.7`。
48
48
 
49
49
  ---
50
50
 
@@ -871,6 +871,17 @@ function applySunMessage(d) {
871
871
  if (typeof d.quality === 'string') applyQuality(d.quality);
872
872
  // 1.5.27: frame rate is a budget dial of its own, on top of the resolution one
873
873
  if (Number.isFinite(Number(d.fps))) applyFpsCap(Number(d.fps));
874
+ // 2.0.7 performance switches (panel: "性能" group + advanced rows)
875
+ if (d.perfScheduler !== undefined) perfScheduler = d.perfScheduler !== false;
876
+ if (d.webgl2Adaptive !== undefined) webgl2Adaptive = d.webgl2Adaptive !== false;
877
+ if (d.resizeDebounce !== undefined) resizeDebounce = d.resizeDebounce !== false;
878
+ if (typeof d.pauseWhenObscured === 'string' && PAUSE_MODES.includes(d.pauseWhenObscured)) {
879
+ const modeChanged = pauseMode !== d.pauseWhenObscured;
880
+ pauseMode = d.pauseWhenObscured;
881
+ // the switch may flip while the window is already in the background, so the
882
+ // loop state has to be re-evaluated now, not on the next occlude event
883
+ if (modeChanged && loopStarted && renderer) applyObscuredLoop();
884
+ }
874
885
  const t = Number(d.t);
875
886
  if (sunMode === 'manual') {
876
887
  manualTimeOfDay = d.auto !== true;
@@ -1400,6 +1411,13 @@ if (SKIN) {
1400
1411
  // daylight is carried by oss-sun now; a sea tick must never move the sun
1401
1412
  }
1402
1413
  if (d && d.type === 'oss-sun') applySunMessage(d);
1414
+ if (d && d.type === 'oss-active') {
1415
+ // The panel watches its own window: a covered browser window never tells
1416
+ // the iframe (it is not a tab) and never takes focus, so this is the only
1417
+ // signal that the skin went to the background.
1418
+ obscured = d.active === false;
1419
+ applyObscuredLoop();
1420
+ }
1403
1421
  if (d && d.type === 'oss-glass') applyGlassMessage(d);
1404
1422
  if (d && d.type === 'oss-boats') applyBoatsMessage(d);
1405
1423
  if (d && d.type === 'oss-fleet') {
@@ -1503,6 +1521,35 @@ let renderScale = 1;
1503
1521
  // contract §3.4: the frosted glass caps the adaptive scale, it never raises it
1504
1522
  let SCALE_CAP = 1;
1505
1523
 
1524
+ // 2.0.7 performance switches. Every one of them defaults to the shape the user
1525
+ // already knows, so a profile written by 2.0.6 keeps the 2.0.6 frame.
1526
+ let perfScheduler = true;
1527
+ let pauseMode = 'pause'; // pause | throttle | off
1528
+ let webgl2Adaptive = true;
1529
+ let resizeDebounce = true;
1530
+ let webglFallback = false;
1531
+ let webglLatchUntil = 0;
1532
+ let obscured = false;
1533
+ let nextDue = 0;
1534
+ let currentQuality = 'balanced';
1535
+
1536
+ // 2.0.7: under a frame cap the meter can never climb above the cap, so "did the
1537
+ // GPU have headroom?" cannot be read from fps alone. The governor therefore
1538
+ // spends one window at a higher scale and watches whether that window costs
1539
+ // frames; a failed probe walks the scale back and doubles the wait before the
1540
+ // next attempt, so a machine that is genuinely at its limit is not prodded.
1541
+ // Every verdict reallocates the drawing buffer (setPixelRatio + setSize), which
1542
+ // reads as a one-frame blink on the water. That blink is why a proven step is
1543
+ // followed by a long hold: the governor climbs in a few visible steps and then
1544
+ // stops, instead of pulsing the sharpness every couple of seconds.
1545
+ let probeState = 0; // 0 = idle, 1 = one window raised, waiting for the verdict
1546
+ let probeBackoff = 4; // windows to wait after a failed probe (4 → 64 s)
1547
+ let probeBaseScale = 1;
1548
+ const PROBE_STEP = 1.03;
1549
+ const PROBE_BACKOFF_MAX = 32;
1550
+ const PROBE_HOLD = 12; // 24 s of quiet after a step is kept
1551
+ const PROBE_CAP_EPS = 0.015; // do not hunt the last 1.5 % of the cap
1552
+
1506
1553
  function applyResolutionScale(scale) {
1507
1554
  renderer.setPixelRatio(BASE_PIXEL_RATIO * scale);
1508
1555
  renderer.setSize(window.innerWidth, window.innerHeight);
@@ -1520,6 +1567,7 @@ const QUALITY_PRESETS = {
1520
1567
  // 60 fps, and every frame is a full-screen fill: halving the rate halves the
1521
1568
  // GPU time. SKIN defaults to 30; the standalone demo keeps its old cap.
1522
1569
  const FPS_OPTIONS = [30, 60, 90, 120];
1570
+ const PAUSE_MODES = ['pause', 'throttle', 'off'];
1523
1571
  let fpsCap = SKIN ? 30 : 60;
1524
1572
 
1525
1573
  function applyFpsCap(cap) {
@@ -1541,6 +1589,10 @@ function applyFpsCap(cap) {
1541
1589
  function applyQuality(level) {
1542
1590
  const preset = QUALITY_PRESETS[level];
1543
1591
  if (!preset || !renderer) return;
1592
+ currentQuality = level;
1593
+ // a quality switch re-clamps the scale: any probe in flight is meaningless now
1594
+ probeState = 0;
1595
+ probeBackoff = 4;
1544
1596
  PIXEL_RATIO_CAP = REDUCED_MOTION ? Math.min(preset.pixelRatio, 0.9) : preset.pixelRatio;
1545
1597
  BASE_PIXEL_RATIO = Math.min(window.devicePixelRatio || 1, PIXEL_RATIO_CAP);
1546
1598
  TARGET_FPS = REDUCED_MOTION ? 18 : preset.targetFps;
@@ -1551,7 +1603,40 @@ function applyQuality(level) {
1551
1603
  applyFpsCap(fpsCap);
1552
1604
  }
1553
1605
 
1606
+ // 2.0.7: one place that decides whether the loop should be running. `throttle`
1607
+ // keeps the loop alive and lets the gate in animate() stretch the step to 10 fps,
1608
+ // so the sky keeps advancing and coming back does not jump; `pause` stops it
1609
+ // outright, which is what actually drops the GPU to idle when DSH is covered.
1610
+ function applyObscuredLoop() {
1611
+ if (!loopStarted || !renderer) return;
1612
+ // Never pause before the first frame is on screen: the loader is only taken
1613
+ // down by a rendered frame, so a pause here would read as a broken skin.
1614
+ if (!firstFrameShown) return;
1615
+ if (obscured && pauseMode === 'pause') {
1616
+ renderer.setAnimationLoop(null);
1617
+ return;
1618
+ }
1619
+ if (document.hidden) return;
1620
+ lastFrameTime = performance.now();
1621
+ lastRenderAt = 0;
1622
+ nextDue = 0;
1623
+ renderer.setAnimationLoop(animate);
1624
+ }
1625
+
1554
1626
  function animate(now) {
1627
+ // 2.0.7: the whole tick is gated, not just the draw. On a 144 Hz panel the
1628
+ // star billboards, the orbit controls and the fleet used to run 144 times a
1629
+ // second so that 30 frames could be presented. The due time is advanced in
1630
+ // whole frame steps (nextDue += step) instead of being snapped to `now`, and
1631
+ // that matters: a 30 Hz budget sampled on a 144 Hz vsync grid lands on 6.9
1632
+ // ticks, and resetting the anchor to `now` turned that into an alternating
1633
+ // 2/1 cadence. A long stall resyncs instead of trying to catch up in a burst.
1634
+ if (perfScheduler) {
1635
+ const step = obscured && pauseMode === 'throttle' ? Math.max(FRAME_MS, 100) : FRAME_MS;
1636
+ if (now + 0.5 < nextDue) return;
1637
+ if (now - nextDue > step * 4) nextDue = now;
1638
+ nextDue += step;
1639
+ }
1555
1640
  // high-resolution timestamp -> delta, clamped so hitches never cause jumps
1556
1641
  const dt = Math.min((now - lastFrameTime) / 1000, 0.1);
1557
1642
  lastFrameTime = now;
@@ -1588,8 +1673,11 @@ function animate(now) {
1588
1673
  }
1589
1674
  }
1590
1675
 
1591
- // frame cap: skip work when the display fires faster than 60 Hz
1592
- if (now - lastRenderAt < FRAME_MS - 0.5) return;
1676
+ // frame cap: with the smart scheduler the work above is already gated, so
1677
+ // only the legacy path still needs the mid-tick early return
1678
+ if (!perfScheduler) {
1679
+ if (now - lastRenderAt < FRAME_MS - 0.5) return;
1680
+ }
1593
1681
  lastRenderAt = now;
1594
1682
 
1595
1683
  postProcessing.render();
@@ -1632,10 +1720,51 @@ function animate(now) {
1632
1720
  renderScale = Math.max(MIN_SCALE, +(renderScale * 0.97).toFixed(3));
1633
1721
  applyResolutionScale(renderScale);
1634
1722
  adaptHold = 4;
1635
- } else if (fpsNow > TARGET_FPS + 6 && renderScale < SCALE_CAP) {
1723
+ // a real drop cancels any running probe and resets the backoff: next
1724
+ // time the frame is healthy we start probing from the near end again
1725
+ probeState = 0;
1726
+ probeBackoff = 4;
1727
+ // 2.0.7: a WebGL2 post chain is dearer than the WebGPU one, so once the
1728
+ // governor has walked down there it is not allowed to walk straight
1729
+ // back up and oscillate. The latch only gates the climb, never the drop.
1730
+ if (webglFallback) webglLatchUntil = now + 30000;
1731
+ } else if (
1732
+ fpsNow > TARGET_FPS + 6
1733
+ && renderScale < SCALE_CAP
1734
+ && (!webglFallback || now > webglLatchUntil)
1735
+ ) {
1736
+ // uncapped headroom: the meter itself proved there is room
1636
1737
  renderScale = Math.min(SCALE_CAP, +(renderScale * 1.015).toFixed(3));
1637
1738
  applyResolutionScale(renderScale);
1638
1739
  adaptHold = 4;
1740
+ probeState = 0;
1741
+ probeBackoff = 4;
1742
+ } else if (probeState === 1) {
1743
+ // verdict on the probe we started last window: holding the target under
1744
+ // a cap means the extra pixels were free, so the step is kept
1745
+ if (fpsNow >= TARGET_FPS - 4) {
1746
+ probeState = 0;
1747
+ probeBackoff = 4;
1748
+ adaptHold = PROBE_HOLD;
1749
+ } else {
1750
+ renderScale = probeBaseScale;
1751
+ applyResolutionScale(renderScale);
1752
+ probeState = 0;
1753
+ probeBackoff = Math.min(PROBE_BACKOFF_MAX, probeBackoff * 2);
1754
+ adaptHold = probeBackoff;
1755
+ }
1756
+ } else if (
1757
+ fpsNow >= TARGET_FPS - 2
1758
+ && renderScale < SCALE_CAP - PROBE_CAP_EPS
1759
+ && (!webglFallback || now > webglLatchUntil)
1760
+ ) {
1761
+ probeBaseScale = renderScale;
1762
+ renderScale = Math.min(SCALE_CAP, +(renderScale * PROBE_STEP).toFixed(3));
1763
+ applyResolutionScale(renderScale);
1764
+ probeState = 1;
1765
+ adaptHold = 1; // judge it in the very next window
1766
+ } else {
1767
+ adaptHold = 1; // between the dead bands: look again in two seconds
1639
1768
  }
1640
1769
  }
1641
1770
  }
@@ -1664,7 +1793,7 @@ function animate(now) {
1664
1793
  }
1665
1794
  }
1666
1795
 
1667
- const OCEAN_BUILD = 'lab-2.0.4';
1796
+ const OCEAN_BUILD = 'lab-2.0.7';
1668
1797
 
1669
1798
  // How often the frame reports its own state to the host page.
1670
1799
  const STATE_POST_MS = 400;
@@ -1853,10 +1982,30 @@ async function boot() {
1853
1982
  }
1854
1983
 
1855
1984
  // --- lifecycle: resize ---------------------------------------------------
1856
- window.addEventListener('resize', () => {
1985
+ const rebuildForResize = () => {
1857
1986
  camera.aspect = window.innerWidth / window.innerHeight;
1858
1987
  camera.updateProjectionMatrix();
1859
1988
  applyResolutionScale(renderScale);
1989
+ };
1990
+ // 2.0.7: dragging a window edge fires resize every pixel, and every one of
1991
+ // them reallocated the render targets. The old canvas is stretched over the
1992
+ // viewport while the drag is in flight (no gap, no black band) and the
1993
+ // targets are rebuilt once, 150 ms after the drag settles.
1994
+ let resizeTimer = 0;
1995
+ window.addEventListener('resize', () => {
1996
+ if (!resizeDebounce) {
1997
+ rebuildForResize();
1998
+ return;
1999
+ }
2000
+ if (!resizeTimer && renderer && renderer.domElement && renderer.domElement.style) {
2001
+ renderer.domElement.style.width = '100%';
2002
+ renderer.domElement.style.height = '100%';
2003
+ }
2004
+ if (resizeTimer) window.clearTimeout(resizeTimer);
2005
+ resizeTimer = window.setTimeout(() => {
2006
+ resizeTimer = 0;
2007
+ rebuildForResize();
2008
+ }, 150);
1860
2009
  });
1861
2010
 
1862
2011
  // --- lifecycle: pause when the tab is hidden -------------------------------
@@ -1864,11 +2013,11 @@ async function boot() {
1864
2013
  if (!loopStarted) return;
1865
2014
  if (document.hidden) {
1866
2015
  renderer.setAnimationLoop(null);
1867
- } else {
1868
- lastFrameTime = performance.now();
1869
- lastRenderAt = 0;
1870
- renderer.setAnimationLoop(animate);
2016
+ return;
1871
2017
  }
2018
+ // a minimized window is hidden first and covered second; coming back
2019
+ // re-runs the same decision the panel's activity signal uses
2020
+ applyObscuredLoop();
1872
2021
  });
1873
2022
 
1874
2023
  // --- initialize the GPU and start rendering --------------------------------
@@ -1877,13 +2026,23 @@ async function boot() {
1877
2026
  if (renderer.backend.isWebGPUBackend !== true) {
1878
2027
  // WebGPU 不可用时 three 自动回退 WebGL2 backend:两种 backend 都允许渲染
1879
2028
  // (2026-08-23 webgui 冒烟测试:用户浏览器无 WebGPU → 纯 WebGPU 检查导致初始化失败)。
1880
- PIXEL_RATIO_CAP = 1.0;
1881
- BASE_PIXEL_RATIO = Math.min(window.devicePixelRatio || 1, PIXEL_RATIO_CAP);
1882
- FRAME_RATE_CAP = 30;
1883
- FRAME_MS = 1000 / FRAME_RATE_CAP;
1884
- TARGET_FPS = 28;
1885
- renderScale = 0.6;
1886
- applyResolutionScale(renderScale);
2029
+ webglFallback = true;
2030
+ if (webgl2Adaptive) {
2031
+ // 2.0.7: no hardcoded 30 fps / 0.6 any more. The panel's tier is applied
2032
+ // as-is and the governor walks it down if WebGL2 cannot hold it; the
2033
+ // 30 s latch in the governor stops it from climbing back immediately.
2034
+ webglLatchUntil = performance.now() + 30000;
2035
+ if (QUALITY_PRESETS[currentQuality]) applyQuality(currentQuality);
2036
+ else applyQuality('balanced');
2037
+ } else {
2038
+ PIXEL_RATIO_CAP = 1.0;
2039
+ BASE_PIXEL_RATIO = Math.min(window.devicePixelRatio || 1, PIXEL_RATIO_CAP);
2040
+ FRAME_RATE_CAP = 30;
2041
+ FRAME_MS = 1000 / FRAME_RATE_CAP;
2042
+ TARGET_FPS = 28;
2043
+ renderScale = 0.6;
2044
+ applyResolutionScale(renderScale);
2045
+ }
1887
2046
  }
1888
2047
 
1889
2048
  loopStarted = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-sea-skin-dsh",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "type": "module",
5
5
  "description": "实时海洋皮肤 · Realtime ocean theme for DeepSeek Harness Desktop and Web",
6
6
  "repository": {
package/plugin/client.js CHANGED
@@ -96,6 +96,13 @@ window.__ModuleLoader__.load({ id: "open-sea-skin-dsh", factory: (require) => {
96
96
  colLeft: Object.freeze({ on: true, strength: 28 }),
97
97
  colRight: Object.freeze({ on: true, strength: 28 }),
98
98
  perf: false,
99
+ // 2.0.7 performance switches: every one of these can be turned off and the
100
+ // 2.0.6 frame comes back unchanged
101
+ perfScheduler: true,
102
+ pauseWhenObscured: 'pause',
103
+ webgl2Adaptive: true,
104
+ resizeDebounce: true,
105
+ glassBlurMode: 'live',
99
106
  boats: true,
100
107
  boatSpread: 2,
101
108
  boatPace: 1,
@@ -158,6 +165,11 @@ window.__ModuleLoader__.load({ id: "open-sea-skin-dsh", factory: (require) => {
158
165
  glassMode: '渲染器内 = 毛玻璃在 WebGPU 里做(可逐栏、可调半径);CSS 模糊 = 交给浏览器,省 GPU 但只有一档。',
159
166
  glassStyle: '模糊的取样形状:高斯最自然、盒式最省、双层更厚、径向中间清楚边缘糊、磨砂有颗粒、玻璃砖成块。',
160
167
  perf: '性能模式:一键关掉毛玻璃与额外采样,只保留海面与天空。',
168
+ perfScheduler: '智能帧调度:把整帧逻辑(星点朝向、船只、相机)也按帧率上限节流,不再在 144Hz 屏上空跑;时间仍按真实时钟推进,海面不会跳。',
169
+ pauseWhenObscured: '后台停止渲染:DSH 窗口被别的窗口盖住时停画或降频。停止最省电,降频保留 10fps 让天色继续走,关闭则一直满帧。',
170
+ webgl2Adaptive: '无 WebGPU 时自适应:没有 WebGPU 的机器不再被死钉在 30fps / 0.6×,而是按画质档位跑并由自适应调节;关掉即回到旧的固定降级。',
171
+ resizeDebounce: '窗口缩放防抖:拖动窗口边缘时先拉伸旧画面,停手 150ms 后再按新尺寸重建渲染目标,避免每像素重建一次。',
172
+ glassBlurMode: '毛玻璃刷新方式:实时 = backdrop-filter 每帧重新取样(最好看也最贵);静态 = 只保留底色、不再实时取样;关闭 = 连接底色一起去掉。仅在「CSS 模糊」模式下生效。',
161
173
  frost: '背景模糊总开关。关掉后三栏与外壳都不再糊,但设置面板仍保留 9px 底模糊以便阅读。',
162
174
  cols: {
163
175
  left: '左侧栏(会话/项目列表)。',
@@ -255,6 +267,13 @@ window.__ModuleLoader__.load({ id: "open-sea-skin-dsh", factory: (require) => {
255
267
  secMeteor: '流星',
256
268
  secClouds: '云',
257
269
  secAdvanced: '高级',
270
+ secPerf: '性能',
271
+ perfScheduler: '智能帧调度',
272
+ pauseWhenObscured: '后台停止渲染',
273
+ webgl2Adaptive: '无 WebGPU 时自适应',
274
+ resizeDebounce: '窗口缩放防抖',
275
+ glassBlurMode: '毛玻璃刷新方式',
276
+ perfNote: '这一组只管「什么时候不画」与「画多快」,不改画面本身;想改像素预算用下面的画质档位,想改帧率上限用帧率档。',
258
277
  skyTone: '夜色倾向',
259
278
  domeAmount: '穹顶弧度',
260
279
  starSize: '星点大小',
@@ -316,6 +335,8 @@ window.__ModuleLoader__.load({ id: "open-sea-skin-dsh", factory: (require) => {
316
335
  sunModes: { real: '真实时钟', manual: '手动' },
317
336
  grades: { off: '关', low: '少', mid: '中', high: '多' },
318
337
  glassModes: { render: '渲染器内', css: 'CSS 模糊' },
338
+ pauseModes: { pause: '停止', throttle: '降频', off: '关闭' },
339
+ glassBlurModes: { live: '实时', static: '静态', off: '关闭' },
319
340
  levels: { off: '关', weak: '弱', medium: '中', strong: '强' },
320
341
  },
321
342
  en: {
@@ -364,6 +385,11 @@ window.__ModuleLoader__.load({ id: "open-sea-skin-dsh", factory: (require) => {
364
385
  glassMode: 'Renderer = blur inside WebGPU (per column, real radius); CSS = the browser does it: cheaper, one radius only.',
365
386
  glassStyle: 'Kernel shape: gaussian is the most natural, box the cheapest, double thicker, radial sharp centre, frost grainy, tile blocky.',
366
387
  perf: 'Performance mode: drops the glass and extra taps, keeps water and sky.',
388
+ perfScheduler: 'Smart frame scheduler: the per-frame logic (star billboards, boats, camera) runs at the frame cap too, instead of 144 times a second on a 144 Hz panel to present 30 frames. Time still advances on the wall clock, so the sea never jumps.',
389
+ pauseWhenObscured: 'Stop in background: when another window covers DSH, stop drawing or slow down. Stop saves the most; throttle keeps 10 fps so the sky keeps moving; off always runs at full rate.',
390
+ webgl2Adaptive: 'Adaptive without WebGPU: a machine without WebGPU is no longer pinned to 30 fps / 0.6x; it runs the chosen tier and lets the governor adjust. Off restores the old fixed fallback.',
391
+ resizeDebounce: 'Debounce resize: while you drag a window edge the old frame is stretched; the render targets are rebuilt 150 ms after you let go, not once per pixel.',
392
+ glassBlurMode: 'Glass blur refresh: live = backdrop-filter re-samples every frame (best looking, most expensive); static = keep the fill, drop the live sample; off = drop the fill too. Only applies in CSS blur mode.',
367
393
  frost: 'Master background blur. Off leaves all columns sharp, while the settings panel keeps a 9px floor so it stays readable.',
368
394
  cols: {
369
395
  left: 'Left column: the session and project list.',
@@ -461,6 +487,13 @@ window.__ModuleLoader__.load({ id: "open-sea-skin-dsh", factory: (require) => {
461
487
  secMeteor: 'Meteors',
462
488
  secClouds: 'Clouds',
463
489
  secAdvanced: 'Advanced',
490
+ secPerf: 'Performance',
491
+ perfScheduler: 'Smart frame scheduler',
492
+ pauseWhenObscured: 'Stop in background',
493
+ webgl2Adaptive: 'Adaptive without WebGPU',
494
+ resizeDebounce: 'Debounce resize',
495
+ glassBlurMode: 'Glass blur refresh',
496
+ perfNote: 'This group only decides when to stop drawing and how fast to draw; it never changes the picture itself. Pixel budget stays on the quality tier below, the frame cap stays on its own dial.',
464
497
  skyTone: 'Night tint',
465
498
  domeAmount: 'Dome curve',
466
499
  starSize: 'Star size',
@@ -522,6 +555,8 @@ window.__ModuleLoader__.load({ id: "open-sea-skin-dsh", factory: (require) => {
522
555
  sunModes: { real: 'Real clock', manual: 'Manual' },
523
556
  grades: { off: 'Off', low: 'Low', mid: 'Mid', high: 'High' },
524
557
  glassModes: { render: 'In renderer', css: 'CSS blur' },
558
+ pauseModes: { pause: 'Stop', throttle: 'Throttle', off: 'Off' },
559
+ glassBlurModes: { live: 'Live', static: 'Static', off: 'Off' },
525
560
  levels: { off: 'Off', weak: 'Weak', medium: 'Mid', strong: 'Strong' },
526
561
  },
527
562
  });
@@ -557,6 +592,10 @@ window.__ModuleLoader__.load({ id: "open-sea-skin-dsh", factory: (require) => {
557
592
  const TZ_MODES = ['system', 'longitude', 'manual'];
558
593
  const QUALITY_LEVELS = ['high', 'balanced', 'low'];
559
594
  const FPS_LEVELS = [30, 60, 90, 120];
595
+ // 2.0.7: how the surface behaves while it is in the background, and how the CSS
596
+ // glass fallback refreshes itself
597
+ const PAUSE_MODES = ['pause', 'throttle', 'off'];
598
+ const GLASS_BLUR_MODES = ['live', 'static', 'off'];
560
599
 
561
600
  // 2.0.4: a tier carries a suggested cap so the two dials never disagree on the
562
601
  // first click. The cap stays its own dial: changing it afterwards does not move
@@ -649,6 +688,11 @@ const PRESET_FPS = { high: 90, balanced: 60, low: 30 };
649
688
  colLeft: normalizeColumn(source.colLeft, DEFAULTS.colLeft),
650
689
  colRight: normalizeColumn(source.colRight, DEFAULTS.colRight),
651
690
  perf: source.perf === true,
691
+ perfScheduler: source.perfScheduler !== false,
692
+ pauseWhenObscured: PAUSE_MODES.includes(source.pauseWhenObscured) ? source.pauseWhenObscured : DEFAULTS.pauseWhenObscured,
693
+ webgl2Adaptive: source.webgl2Adaptive !== false,
694
+ resizeDebounce: source.resizeDebounce !== false,
695
+ glassBlurMode: GLASS_BLUR_MODES.includes(source.glassBlurMode) ? source.glassBlurMode : DEFAULTS.glassBlurMode,
652
696
  boats: source.boats !== false,
653
697
  boatSpread: clamp(source.boatSpread, 0.2, 3, DEFAULTS.boatSpread),
654
698
  boatPace: clamp(source.boatPace, 0.3, 2.5, DEFAULTS.boatPace),
@@ -687,6 +731,19 @@ const PRESET_FPS = { high: 90, balanced: 60, low: 30 };
687
731
 
688
732
  function blurCss(state) {
689
733
  if (!state.frost || state.glassMode !== 'css' || state.perf) return '';
734
+ const columns = 'body [data-slot="sidebar"], body .dshDesktopSidebarSurface, body [data-rightbar-col], body .dshDesktopDetailsSurface';
735
+ // 2.0.7: a backdrop-filter is re-sampled by the compositor every frame the
736
+ // scene under it changes, which is every frame. `static` keeps the frosted
737
+ // fill (painted by glassCss) but stops re-sampling; `off` also drops the
738
+ // column fill, so the columns read as plain windows.
739
+ if (state.glassBlurMode === 'static') {
740
+ return `/* static glass: fill stays, the live backdrop sample does not */
741
+ ${columns} { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }`;
742
+ }
743
+ if (state.glassBlurMode === 'off') {
744
+ return `/* glass blur off: no live sample and no column fill */
745
+ ${columns} { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; background-color: transparent !important; }`;
746
+ }
690
747
  const column = (selector, key) => {
691
748
  const entry = state[key];
692
749
  // one radius for the frame, each column takes a share of it
@@ -1075,6 +1132,12 @@ body:not([data-ds-dark-theme]) #${IDS.button}.active { color:#0a4f4d; border-col
1075
1132
  overflow: seaOverflow(),
1076
1133
  quality: state.quality,
1077
1134
  fps: state.fps,
1135
+ // 2.0.7 performance switches; glassBlurMode stays on this side, it only
1136
+ // rewrites our own CSS and never reaches the renderer
1137
+ perfScheduler: state.perfScheduler,
1138
+ pauseWhenObscured: state.pauseWhenObscured,
1139
+ webgl2Adaptive: state.webgl2Adaptive,
1140
+ resizeDebounce: state.resizeDebounce,
1078
1141
  glow: state.glow,
1079
1142
  });
1080
1143
  };
@@ -1709,6 +1772,11 @@ body:not([data-ds-dark-theme]) #${IDS.button}.active { color:#0a4f4d; border-col
1709
1772
  frame.addEventListener('load', () => {
1710
1773
  postSun();
1711
1774
  postGlass();
1775
+ // a fresh frame boots believing it is on screen; correct that at once
1776
+ if (typeof postActive === 'function') {
1777
+ lastActive = null;
1778
+ postActive();
1779
+ }
1712
1780
  postBoats();
1713
1781
  postToOcean(state.waveMode === 'manual' ? state.sea : seaValue);
1714
1782
  // The frame boots with one boat on the water; the poll below reposts the
@@ -1718,6 +1786,34 @@ body:not([data-ds-dark-theme]) #${IDS.button}.active { color:#0a4f4d; border-col
1718
1786
  if (glassTimer) window.clearInterval(glassTimer);
1719
1787
  glassTimer = window.setInterval(postGlass, 1500);
1720
1788
  }, { once: true });
1789
+ // 2.0.7: forward whether this window is actually the one on screen. The
1790
+ // surface lives in an iframe, which is never a tab: it sees neither
1791
+ // visibilitychange (that one it does see) nor focus (that one it never
1792
+ // gets), so a covered DSH window looked identical to an idle one and the
1793
+ // sea kept drawing at full rate behind whatever was on top. Debounced,
1794
+ // because alt-tabbing flickers focus and stopping/starting per flicker
1795
+ // would cost more than it saves.
1796
+ let activeTimer = 0;
1797
+ let lastActive = null;
1798
+ const postActive = () => {
1799
+ const active = !document.hidden && document.hasFocus();
1800
+ if (active === lastActive) return;
1801
+ lastActive = active;
1802
+ post({ type: 'oss-active', active });
1803
+ };
1804
+ const scheduleActive = () => {
1805
+ if (activeTimer) window.clearTimeout(activeTimer);
1806
+ activeTimer = window.setTimeout(postActive, 500);
1807
+ };
1808
+ window.addEventListener('focus', scheduleActive);
1809
+ window.addEventListener('blur', scheduleActive);
1810
+ document.addEventListener('visibilitychange', scheduleActive);
1811
+ surfaceCleanups.push(() => {
1812
+ window.removeEventListener('focus', scheduleActive);
1813
+ window.removeEventListener('blur', scheduleActive);
1814
+ document.removeEventListener('visibilitychange', scheduleActive);
1815
+ if (activeTimer) window.clearTimeout(activeTimer);
1816
+ });
1721
1817
  ownsSurface = true;
1722
1818
  return true;
1723
1819
  };
@@ -1836,11 +1932,18 @@ body:not([data-ds-dark-theme]) #${IDS.button}.active { color:#0a4f4d; border-col
1836
1932
  <p class="oss-sub" data-copy="colsNote"></p>
1837
1933
  ${switchRow(pid('perf'), 'perf')}
1838
1934
  <p class="oss-sub" data-copy="glassNote"></p>`)}
1935
+ ${group('secPerf', `
1936
+ ${switchRow(pid('perf-scheduler'), 'perfScheduler')}
1937
+ ${segRow(pid('pause-mode'), 'pauseWhenObscured', [['pause', 'pauseModes.pause'], ['throttle', 'pauseModes.throttle'], ['off', 'pauseModes.off']])}
1938
+ ${switchRow(pid('webgl2-adaptive'), 'webgl2Adaptive')}
1939
+ <p class="oss-sub" data-copy="perfNote"></p>`)}
1839
1940
  ${group('secAdvanced', `
1840
1941
  <p class="oss-sub" data-copy="perfGroup"></p>
1841
1942
  ${segRow(pid('quality'), 'qualityMode', [['high', 'quality.high'], ['balanced', 'quality.balanced'], ['low', 'quality.low']])}
1842
1943
  ${rangeRow(pid('glow'), 'glow', 0, 100, 1)}
1843
1944
  ${segRow(pid('fps'), 'fpsMode', [['30', 'fps.30'], ['60', 'fps.60'], ['90', 'fps.90'], ['120', 'fps.120']])}
1945
+ ${switchRow(pid('resize-debounce'), 'resizeDebounce')}
1946
+ ${segRow(pid('glass-blur-mode'), 'glassBlurMode', [['live', 'glassBlurModes.live'], ['static', 'glassBlurModes.static'], ['off', 'glassBlurModes.off']])}
1844
1947
  <p class="oss-sub" id="${pid('perf-out')}"></p>
1845
1948
  <div class="oss-row"><button type="button" class="oss-reset" id="${pid('make-default')}" data-copy="makeDefault"></button></div>
1846
1949
  <p class="oss-sub" id="${pid('default-out')}"></p>
@@ -1857,6 +1960,9 @@ ${switchRow(pid('perf'), 'perf')}
1857
1960
  const moonBox = el('moon');
1858
1961
  const perfBox = el('perf');
1859
1962
  const frostBox = el('frost');
1963
+ const schedulerBox = el('perf-scheduler');
1964
+ const webgl2Box = el('webgl2-adaptive');
1965
+ const resizeDebounceBox = el('resize-debounce');
1860
1966
  const milkyOut = el('milky-out');
1861
1967
  const boatsBox = el('boats');
1862
1968
  const meteorBox = el('meteor');
@@ -1970,6 +2076,9 @@ ${switchRow(pid('perf'), 'perf')}
1970
2076
  moonBox.checked = state.moon;
1971
2077
  perfBox.checked = state.perf;
1972
2078
  if (frostBox) frostBox.checked = state.frost;
2079
+ if (schedulerBox) schedulerBox.checked = state.perfScheduler;
2080
+ if (webgl2Box) webgl2Box.checked = state.webgl2Adaptive;
2081
+ if (resizeDebounceBox) resizeDebounceBox.checked = state.resizeDebounce;
1973
2082
  if (meteorBox) meteorBox.checked = state.meteor;
1974
2083
  if (daylightBox) daylightBox.checked = state.starsDaylight;
1975
2084
  if (boatsBox) boatsBox.checked = state.boats;
@@ -1984,6 +2093,8 @@ ${switchRow(pid('perf'), 'perf')}
1984
2093
  // every performance button unlit even though one of them was in effect
1985
2094
  setSeg(el('quality'), state.quality);
1986
2095
  setSeg(el('fps'), String(state.fps));
2096
+ setSeg(el('pause-mode'), state.pauseWhenObscured);
2097
+ setSeg(el('glass-blur-mode'), state.glassBlurMode);
1987
2098
  if (milkyOut) milkyOut.textContent = milkyText();
1988
2099
  for (const [key, entry] of Object.entries(columns)) {
1989
2100
  const entryState = state[key];
@@ -2159,6 +2270,8 @@ ${switchRow(pid('perf'), 'perf')}
2159
2270
  case pid('glass-style'): return { glassStyle: value };
2160
2271
  case pid('quality'): return { quality: value, fps: PRESET_FPS[value] || state.fps };
2161
2272
  case pid('fps'): return { fps: Number(value) };
2273
+ case pid('pause-mode'): return { pauseWhenObscured: value };
2274
+ case pid('glass-blur-mode'): return { glassBlurMode: value };
2162
2275
  default: return null;
2163
2276
  }
2164
2277
  })();
@@ -2225,6 +2338,24 @@ ${switchRow(pid('perf'), 'perf')}
2225
2338
  updateGlass();
2226
2339
  postGlass();
2227
2340
  });
2341
+ if (schedulerBox) {
2342
+ on(schedulerBox, 'change', () => {
2343
+ void save({ perfScheduler: schedulerBox.checked });
2344
+ postSun();
2345
+ });
2346
+ }
2347
+ if (webgl2Box) {
2348
+ on(webgl2Box, 'change', () => {
2349
+ void save({ webgl2Adaptive: webgl2Box.checked });
2350
+ postSun();
2351
+ });
2352
+ }
2353
+ if (resizeDebounceBox) {
2354
+ on(resizeDebounceBox, 'change', () => {
2355
+ void save({ resizeDebounce: resizeDebounceBox.checked });
2356
+ postSun();
2357
+ });
2358
+ }
2228
2359
  const makeDefaultBtn = el('make-default');
2229
2360
  const defaultOut = el('default-out');
2230
2361
  const refreshDefaultOut = () => {
@@ -2568,7 +2699,7 @@ ${switchRow(pid('perf'), 'perf')}
2568
2699
  // Stamped into both halves so a report can name which copy the browser is
2569
2700
  // actually running: the panel and the frame are loaded by different paths and
2570
2701
  // a stale one looks exactly like a bug.
2571
- const SKIN_BUILD = 'lab-2.0.4';
2702
+ const SKIN_BUILD = 'lab-2.0.7';
2572
2703
 
2573
2704
  exports.name = 'open-sea-skin-client';
2574
2705
  exports.inject = ['slots'];