prts-terrarchive 0.2.0
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/GAME_ASSETS.md +26 -0
- package/LICENSE +21 -0
- package/README.en.md +274 -0
- package/README.md +423 -0
- package/THIRD_PARTY_NOTICES.md +61 -0
- package/bin/bake-rhine-details.mjs +34 -0
- package/bin/install.js +273 -0
- package/bin/pack-map-assets.mjs +110 -0
- package/bin/preview-rhine.mjs +71 -0
- package/bin/rhine-detail-baker.ts +84 -0
- package/contracts/cloud_tools_v1.schema.json +243 -0
- package/contracts/corpus_i18n_v1.schema.json +179 -0
- package/contracts/corpus_tools_v1.schema.json +1801 -0
- package/cordis.patch.yml +32 -0
- package/docs/endfield-official-i18n.md +103 -0
- package/lib/client.js +3912 -0
- package/lib/endfield-map/map.js.br +0 -0
- package/lib/endfield-map/map.js.gz +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.br +0 -0
- package/lib/endfield-map/resources/map-00b0d0744a1b4404.json.gz +0 -0
- package/lib/endfield-map/resources/map-01616f534aaaeca1.png +0 -0
- package/lib/endfield-map/resources/map-1562e0dff4be3d23.png +0 -0
- package/lib/endfield-map/resources/map-160fe040f080f6a3.png +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.br +0 -0
- package/lib/endfield-map/resources/map-17b9d8be25dc2b39.json.gz +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.br +0 -0
- package/lib/endfield-map/resources/map-1c4245c4e8b80c43.json.gz +0 -0
- package/lib/endfield-map/resources/map-1d30baaaef3e29cc.png +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.br +0 -0
- package/lib/endfield-map/resources/map-21a61386af79cd12.json.gz +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.br +0 -0
- package/lib/endfield-map/resources/map-24b920b8dab49b44.json.gz +0 -0
- package/lib/endfield-map/resources/map-26caf8d71247abb7.png +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.br +0 -0
- package/lib/endfield-map/resources/map-2729942c89545e73.json.gz +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.br +0 -0
- package/lib/endfield-map/resources/map-29063a9802f67014.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.br +0 -0
- package/lib/endfield-map/resources/map-2f35d09a3ab60a63.json.gz +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.br +0 -0
- package/lib/endfield-map/resources/map-2f98a873da81425e.json.gz +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.br +0 -0
- package/lib/endfield-map/resources/map-3ab978ae44190f80.json.gz +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.br +0 -0
- package/lib/endfield-map/resources/map-3b62c80d14208902.json.gz +0 -0
- package/lib/endfield-map/resources/map-3d486e887ae121ac.png +0 -0
- package/lib/endfield-map/resources/map-418dc4865608fce5.png +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.br +0 -0
- package/lib/endfield-map/resources/map-4366b84607335f76.json.gz +0 -0
- package/lib/endfield-map/resources/map-4371259d3ab7aac7.png +0 -0
- package/lib/endfield-map/resources/map-48dcc0fd55e13f7f.png +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.br +0 -0
- package/lib/endfield-map/resources/map-4aabda0e41197570.json.gz +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.br +0 -0
- package/lib/endfield-map/resources/map-505f5687b287043c.json.gz +0 -0
- package/lib/endfield-map/resources/map-5177e9d1d46f47e2.png +0 -0
- package/lib/endfield-map/resources/map-51baeb59b7f8c28d.png +0 -0
- package/lib/endfield-map/resources/map-549561b46e621874.png +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.br +0 -0
- package/lib/endfield-map/resources/map-56c6a0bef799c8dc.json.gz +0 -0
- package/lib/endfield-map/resources/map-5a6963909423b674.png +0 -0
- package/lib/endfield-map/resources/map-5a9c533c0552d7b7.png +0 -0
- package/lib/endfield-map/resources/map-5d20ae9ffa9a02b0.png +0 -0
- package/lib/endfield-map/resources/map-626c8847393f60bf.png +0 -0
- package/lib/endfield-map/resources/map-63d43cbec3bed3b8.png +0 -0
- package/lib/endfield-map/resources/map-661b6ec56e722574.png +0 -0
- package/lib/endfield-map/resources/map-6ddd384eca190828.png +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.br +0 -0
- package/lib/endfield-map/resources/map-6fc1aa9144f8156c.json.gz +0 -0
- package/lib/endfield-map/resources/map-738ebaa002c33cfc.png +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.br +0 -0
- package/lib/endfield-map/resources/map-758c13be6085ec07.json.gz +0 -0
- package/lib/endfield-map/resources/map-78929f26d6844509.png +0 -0
- package/lib/endfield-map/resources/map-7f09c142950a0f74.png +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.br +0 -0
- package/lib/endfield-map/resources/map-8385e11f39e1a51c.json.gz +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.br +0 -0
- package/lib/endfield-map/resources/map-881343926a568f27.json.gz +0 -0
- package/lib/endfield-map/resources/map-8d1ade3f01d952b0.png +0 -0
- package/lib/endfield-map/resources/map-8fef3495bb7ed60f.png +0 -0
- package/lib/endfield-map/resources/map-907a7ecf8a0ce352.png +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.br +0 -0
- package/lib/endfield-map/resources/map-9113d5951c4db903.json.gz +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.br +0 -0
- package/lib/endfield-map/resources/map-94ad6634436224e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-99722e61da5f3e53.png +0 -0
- package/lib/endfield-map/resources/map-9be9596cc059abe3.png +0 -0
- package/lib/endfield-map/resources/map-a4db9a4763e47fbf.png +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.br +0 -0
- package/lib/endfield-map/resources/map-a4e8ef07159997e7.json.gz +0 -0
- package/lib/endfield-map/resources/map-ab35d06875ee418f.png +0 -0
- package/lib/endfield-map/resources/map-ac51bb3e7623cd8b.png +0 -0
- package/lib/endfield-map/resources/map-ac830a66296bc83a.png +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.br +0 -0
- package/lib/endfield-map/resources/map-b3fd04209718e55e.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8164f1af7c5d895.png +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.br +0 -0
- package/lib/endfield-map/resources/map-b8cde93b965384d8.json.gz +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.br +0 -0
- package/lib/endfield-map/resources/map-b8e48511502e0e8d.json.gz +0 -0
- package/lib/endfield-map/resources/map-bfc58277d5ba649d.png +0 -0
- package/lib/endfield-map/resources/map-c013f82048c6891a.png +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.br +0 -0
- package/lib/endfield-map/resources/map-c49c947841ae4ab5.json.gz +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.br +0 -0
- package/lib/endfield-map/resources/map-c4d1b1c6d6706c81.json.gz +0 -0
- package/lib/endfield-map/resources/map-c607f8b4c8ad8967.png +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.br +0 -0
- package/lib/endfield-map/resources/map-c6f20276b8781326.json.gz +0 -0
- package/lib/endfield-map/resources/map-d51adf5952af1e8e.png +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.br +0 -0
- package/lib/endfield-map/resources/map-d695eface22f8b74.json.gz +0 -0
- package/lib/endfield-map/resources/map-db11d394456a7465.png +0 -0
- package/lib/endfield-map/resources/map-dcc4d218581e3dc6.png +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.br +0 -0
- package/lib/endfield-map/resources/map-ddd43dbb8ab8a4e3.json.gz +0 -0
- package/lib/endfield-map/resources/map-defe5db7dabf748a.png +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.br +0 -0
- package/lib/endfield-map/resources/map-e1aeb833e22520aa.json.gz +0 -0
- package/lib/endfield-map/resources/map-e25ee9af6e493500.png +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.br +0 -0
- package/lib/endfield-map/resources/map-e3824082cd6f747d.json.gz +0 -0
- package/lib/endfield-map/resources/map-e694a91e630b1bd6.png +0 -0
- package/lib/endfield-map/resources/map-eaa93af79d12a87d.png +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.br +0 -0
- package/lib/endfield-map/resources/map-ec9575ee65d7766f.json.gz +0 -0
- package/lib/endfield-map/resources/map-ede421a329dc78a4.png +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.br +0 -0
- package/lib/endfield-map/resources/map-eff3bce8f6888fc5.json.gz +0 -0
- package/lib/endfield-map/resources/map-fb5f4a59a181452c.png +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.br +0 -0
- package/lib/endfield-map/resources/map-fdeaa17eeba90d8e.json.gz +0 -0
- package/lib/rhine/assets/archive-assembly.glb +0 -0
- package/lib/rhine/assets/archive-cassette.glb +0 -0
- package/lib/rhine/fonts/MiSans-Bold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Demibold.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Light.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-Regular.woff2 +0 -0
- package/lib/rhine/fonts/MiSans-license.pdf +0 -0
- package/lib/rhine/fonts/NOTICE.txt +6 -0
- package/lib/rhine/licenses/RhineLabUI-MIT.txt +21 -0
- package/lib/rhine/licenses/entities-BSD-2-Clause.txt +11 -0
- package/lib/rhine/licenses/linkify-it-MIT.txt +22 -0
- package/lib/rhine/licenses/markdown-it-MIT.txt +22 -0
- package/lib/rhine/licenses/mdurl-MIT.txt +45 -0
- package/lib/rhine/licenses/punycode.js-MIT.txt +20 -0
- package/lib/rhine/licenses/rolling-number-MIT.txt +21 -0
- package/lib/rhine/licenses/three-MIT.txt +21 -0
- package/lib/rhine/licenses/uc.micro-MIT.txt +20 -0
- package/lib/rhine/rhine.css +1 -0
- package/lib/rhine/rhine.js +5106 -0
- package/lib/skins/common.css +31 -0
- package/lib/skins/endfield-aic.css +141 -0
- package/lib/skins/prts-agent.css +136 -0
- package/lib/skins/rhine-lab.css +70 -0
- package/package.json +112 -0
- package/presets/definition.js +26 -0
- package/presets/prts/agent.cordis.yml +25 -0
- package/presets/prts/preset.yml +3 -0
- package/presets/register.js +86 -0
- package/resources/entity-routing.json +21 -0
- package/skills/prts-investigation/SKILL.md +121 -0
- package/skills/prts-retrieval/SKILL.md +35 -0
- package/skills/prts-retrieval/references/cloud-tools.md +13 -0
- package/skills/prts-retrieval/references/diagnostics.md +15 -0
- package/skills/prts-retrieval/references/local-tools.md +24 -0
- package/skills/prts-retrieval/references/module-arknights.md +23 -0
- package/skills/prts-retrieval/references/module-dual.md +15 -0
- package/skills/prts-retrieval/references/module-endfield.md +21 -0
- package/skills/prts-retrieval/references/retrieval-process.md +44 -0
- package/skills/prts-retrieval/references/retrieval-recipes-dual.md +34 -0
- package/skills/prts-retrieval/references/retrieval-recipes-endfield.md +37 -0
- package/skills/prts-retrieval/references/retrieval-recipes.md +39 -0
- package/skills/prts-retrieval/references/tools-runtime.md +48 -0
- package/skills/prts-retrieval/references/wiki-schema.md +38 -0
- package/src/archive-presentation.js +287 -0
- package/src/cloud-projection.js +124 -0
- package/src/cloud.js +498 -0
- package/src/entity-recognizer.js +311 -0
- package/src/entity-routing.js +82 -0
- package/src/evidence-state.js +332 -0
- package/src/i18n.js +365 -0
- package/src/index.js +1261 -0
- package/src/installer.js +1362 -0
- package/src/investigation-review.js +87 -0
- package/src/investigation-store.js +579 -0
- package/src/investigation-tools.js +113 -0
- package/src/localization-format.js +23 -0
- package/src/manual-search.js +103 -0
- package/src/read.js +1097 -0
- package/src/release-compatibility.js +73 -0
- package/src/release-watch.js +40 -0
- package/src/search-projection.js +139 -0
- package/src/search.js +1522 -0
- package/src/skill.js +98 -0
- package/src/source-map.js +348 -0
- package/src/state.js +314 -0
- package/src/store.js +1569 -0
- package/src/timeline.js +386 -0
- package/src/ui.js +1353 -0
- package/src/wiki.js +111 -0
- package/tsconfig.rhine.json +15 -0
- package/ui/rhine/archive-navigation-limit.ts +31 -0
- package/ui/rhine/archive-rack.ts +83 -0
- package/ui/rhine/assets/evidence-observatory.webp +0 -0
- package/ui/rhine/board-plane-transform.ts +24 -0
- package/ui/rhine/catalogue.ts +72 -0
- package/ui/rhine/deferred-preparation.ts +146 -0
- package/ui/rhine/evidence-board-controls.css +22 -0
- package/ui/rhine/evidence-board-history.ts +73 -0
- package/ui/rhine/evidence-board-model.ts +189 -0
- package/ui/rhine/evidence-board-panel.ts +762 -0
- package/ui/rhine/evidence-board-scene.ts +548 -0
- package/ui/rhine/evidence-board.css +191 -0
- package/ui/rhine/evidence-card-art.ts +470 -0
- package/ui/rhine/evidence-card-style.ts +17 -0
- package/ui/rhine/evidence-inbox-scene.ts +43 -0
- package/ui/rhine/evidence-inbox.css +29 -0
- package/ui/rhine/evidence-inbox.ts +104 -0
- package/ui/rhine/index.ts +12 -0
- package/ui/rhine/interaction.css +20 -0
- package/ui/rhine/investigation-board-plane.ts +5 -0
- package/ui/rhine/investigation-board.css +34 -0
- package/ui/rhine/investigation-board.ts +348 -0
- package/ui/rhine/investigation-edits.ts +46 -0
- package/ui/rhine/investigation-layout.ts +29 -0
- package/ui/rhine/label-prefetch.ts +34 -0
- package/ui/rhine/looping-title.ts +40 -0
- package/ui/rhine/manual-search.css +41 -0
- package/ui/rhine/manual-search.ts +89 -0
- package/ui/rhine/original/appearance.ts +180 -0
- package/ui/rhine/original/archive-label.ts +84 -0
- package/ui/rhine/original/archive-lighting.ts +39 -0
- package/ui/rhine/original/archive-loop.ts +82 -0
- package/ui/rhine/original/archive-refill.ts +64 -0
- package/ui/rhine/original/array-detail-data.ts +2 -0
- package/ui/rhine/original/array-detail.ts +41 -0
- package/ui/rhine/original/array-interior.ts +411 -0
- package/ui/rhine/original/array-occlusion.ts +166 -0
- package/ui/rhine/original/array-shell.ts +38 -0
- package/ui/rhine/original/array-visibility.ts +309 -0
- package/ui/rhine/original/asset-url.ts +4 -0
- package/ui/rhine/original/brand.ts +12 -0
- package/ui/rhine/original/cassette-lod.ts +81 -0
- package/ui/rhine/original/data.ts +21 -0
- package/ui/rhine/original/decryption.ts +185 -0
- package/ui/rhine/original/detail-signature.ts +18 -0
- package/ui/rhine/original/glass-reveal.ts +45 -0
- package/ui/rhine/original/internal-optics.ts +70 -0
- package/ui/rhine/original/label-renderer.ts +119 -0
- package/ui/rhine/original/label-textures.ts +117 -0
- package/ui/rhine/original/model-viewer.ts +658 -0
- package/ui/rhine/original/motion.ts +116 -0
- package/ui/rhine/original/opaque-backfaces.ts +64 -0
- package/ui/rhine/original/program-preparation.ts +111 -0
- package/ui/rhine/original/quality-renderer.ts +97 -0
- package/ui/rhine/original/quality-settings.ts +111 -0
- package/ui/rhine/original/render-quality.ts +163 -0
- package/ui/rhine/original/scene.ts +2051 -0
- package/ui/rhine/original/shelf-interior.ts +142 -0
- package/ui/rhine/original/ui-transitions.ts +111 -0
- package/ui/rhine/original/viewer-camera.ts +92 -0
- package/ui/rhine/original/viewport-layout.ts +41 -0
- package/ui/rhine/original-ui.css +2334 -0
- package/ui/rhine/readability.css +46 -0
- package/ui/rhine/reading-object.ts +96 -0
- package/ui/rhine/reading-origin.ts +28 -0
- package/ui/rhine/reading-room.css +55 -0
- package/ui/rhine/report-markdown.ts +98 -0
- package/ui/rhine/scene-edge-navigation.ts +84 -0
- package/ui/rhine/scene.ts +1726 -0
- package/ui/rhine/shelf-layout.ts +38 -0
- package/ui/rhine/snapshot-update-gate.ts +22 -0
- package/ui/rhine/source-reading.ts +15 -0
- package/ui/rhine/source-selection.css +164 -0
- package/ui/rhine/temporary-performance-observers.ts +163 -0
- package/ui/rhine/temporary-performance-panel.ts +124 -0
- package/ui/rhine/temporary-performance.css +19 -0
- package/ui/rhine/temporary-performance.ts +552 -0
- package/ui/rhine/temporary-render-diagnostics.ts +105 -0
- package/ui/rhine/tool-activity.ts +69 -0
- package/ui/rhine/types.ts +219 -0
- package/ui/rhine/visible-scene-hit.ts +13 -0
- package/ui/rhine/workbench.css +818 -0
- package/ui/rhine/workbench.ts +2245 -0
- package/ui/rhine/workspace-travel.ts +32 -0
- package/vite.rhine.config.ts +19 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Times are seconds from the reference video's 05:00 frame (25 fps).
|
|
2
|
+
export const smooth = (t: number) => {
|
|
3
|
+
t = Math.max(0, Math.min(1, t));
|
|
4
|
+
return t * t * t * (10 + t * (-15 + 6 * t));
|
|
5
|
+
};
|
|
6
|
+
const bell = (x: number, width: number) => Math.exp(-0.5 * (x / width) ** 2);
|
|
7
|
+
export function archiveWave(row: number, lane: number, time: number) {
|
|
8
|
+
const t = time - 22;
|
|
9
|
+
const phase = row + (lane - 2) * 0.65;
|
|
10
|
+
const enter = smooth(t / 0.32);
|
|
11
|
+
const first = 3 + t * 19;
|
|
12
|
+
const returning = 32 - (t - 2.3) * 24;
|
|
13
|
+
// A crest has shoulders and a trailing trough: neighboring files describe
|
|
14
|
+
// one continuous surface, rather than independent staggered tweens.
|
|
15
|
+
const packet = (distance: number) =>
|
|
16
|
+
2.5 * bell(distance, 3.8) - 0.58 * bell(distance - 6, 3.5);
|
|
17
|
+
return (
|
|
18
|
+
enter *
|
|
19
|
+
(packet(phase - first) * (1 - smooth((t - 2.15) / 0.65)) +
|
|
20
|
+
packet(phase - returning) *
|
|
21
|
+
smooth((t - 2.17) / 0.32) *
|
|
22
|
+
(1 - smooth((t - 3.5) / 0.85)))
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
export function extraction(time: number) {
|
|
26
|
+
return (
|
|
27
|
+
0.4 * smooth((time - 25.58) / 0.82) + 2.95 * smooth((time - 27.55) / 1.3)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
// The returning scan leaves two moving shoulders around the selected file.
|
|
31
|
+
// Their delay grows with distance, so the neighboring files keep moving during
|
|
32
|
+
// the first extraction and settle before the second extraction.
|
|
33
|
+
export function settlingWave(distance: number, time: number) {
|
|
34
|
+
const age = time - 25.05 - Math.abs(distance) * 0.065;
|
|
35
|
+
const envelope = Math.max(
|
|
36
|
+
-0.42,
|
|
37
|
+
2.15 - 0.17 * (Math.sqrt(distance * distance + 1) - 1),
|
|
38
|
+
);
|
|
39
|
+
const rise = smooth(age / 0.62);
|
|
40
|
+
const ring = age > 0 ? Math.sin(age * 5.1) * Math.exp(-age * 1.3) : 0;
|
|
41
|
+
return envelope * (rise + 0.18 * ring * smooth(age / 0.16));
|
|
42
|
+
}
|
|
43
|
+
export function baselineSelectionWave(distance: number, age: number) {
|
|
44
|
+
if (age < 0 || age > 3.2) return 0;
|
|
45
|
+
return (
|
|
46
|
+
0.8 *
|
|
47
|
+
smooth(age / 0.2) *
|
|
48
|
+
Math.exp(-age * 1.15) *
|
|
49
|
+
Math.cos((distance - age * 8) * 0.58) *
|
|
50
|
+
bell(distance - age * 8, 3.4)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Retained for the comparison experiments; the user chose the signed baseline.
|
|
55
|
+
export function selectionWave(distance: number, age: number) {
|
|
56
|
+
return Math.max(0, baselineSelectionWave(distance, age));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The source stays still while the crest expands around it. Squaring the
|
|
60
|
+
// positive cosine gives the ripple zero velocity at its leading/trailing edge.
|
|
61
|
+
export function rippleEnvelope(distance: number, age: number) {
|
|
62
|
+
return (
|
|
63
|
+
smooth(distance / 2.5) * Math.max(0, Math.cos((distance - age * 8) * 0.58))
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function columnStrength(lane: number, focus: number, progress = 1) {
|
|
68
|
+
const selected = 0.25 + 0.75 * bell(lane - focus, 0.55);
|
|
69
|
+
return 1 + (selected - 1) * smooth(progress);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A quiet idle drift, with neighboring cards slightly out of phase.
|
|
73
|
+
// Maximum displacement is 0.102, under 3% of a card's height.
|
|
74
|
+
export function idleWave(row: number, lane: number, time: number) {
|
|
75
|
+
return (
|
|
76
|
+
0.075 * Math.sin((time * Math.PI * 2) / 8 + row * 0.3 - lane * 0.45) +
|
|
77
|
+
0.027 * Math.sin((time * Math.PI * 2) / 13 - row * 0.17 + lane * 0.3)
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function cinematicField(
|
|
82
|
+
row: number,
|
|
83
|
+
lane: number,
|
|
84
|
+
time: number,
|
|
85
|
+
center = 12,
|
|
86
|
+
focus = 2,
|
|
87
|
+
) {
|
|
88
|
+
const handoff = smooth((time - 24.95) / 0.45);
|
|
89
|
+
const selection = smooth((time - 25.4) / 0.95);
|
|
90
|
+
const shoulderTime = time + 0.3 * handoff * (1 - selection);
|
|
91
|
+
return (
|
|
92
|
+
archiveWave(row, lane, time) * (1 - handoff) +
|
|
93
|
+
settlingWave(row - center, shoulderTime) *
|
|
94
|
+
columnStrength(lane, focus, (time - 25.4) / 0.95)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const INSPECTION_LIFT = 4.05;
|
|
99
|
+
export const ALIGNMENT_EPSILON = 0.001;
|
|
100
|
+
// Hold altitude while facing back into the slot. Descent begins only once
|
|
101
|
+
// alignment is complete; this also applies to independently returning copies.
|
|
102
|
+
export function returnStep(angle: number, dt: number, reduced = false) {
|
|
103
|
+
const next = angle * Math.exp(-dt * (reduced ? 35 : 7));
|
|
104
|
+
return Math.abs(next) <= ALIGNMENT_EPSILON ? 0 : next;
|
|
105
|
+
}
|
|
106
|
+
export interface Spring {
|
|
107
|
+
value: number;
|
|
108
|
+
velocity: number;
|
|
109
|
+
}
|
|
110
|
+
export function damp(s: Spring, target: number, rate: number, dt: number) {
|
|
111
|
+
const delta = s.value - target;
|
|
112
|
+
const impulse = s.velocity + rate * delta;
|
|
113
|
+
const decay = Math.exp(-rate * dt);
|
|
114
|
+
s.value = target + (delta + impulse * dt) * decay;
|
|
115
|
+
s.velocity = (s.velocity - rate * impulse * dt) * decay;
|
|
116
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
const closed = new WeakMap<THREE.BufferGeometry, boolean>();
|
|
4
|
+
|
|
5
|
+
/** Exact-position topology check, once during model preparation, never per frame. */
|
|
6
|
+
function closedOutwardSurface(geometry: THREE.BufferGeometry) {
|
|
7
|
+
const cached = closed.get(geometry);
|
|
8
|
+
if (cached !== undefined) return cached;
|
|
9
|
+
const position = geometry.attributes.position, index = geometry.index, normal = geometry.attributes.normal;
|
|
10
|
+
if (!position || !index || !normal) return false;
|
|
11
|
+
const ids: number[] = [], parent: number[] = [], points: THREE.Vector3[] = [];
|
|
12
|
+
const weld = new Map<string, number>(), edges = new Map<string, { count: number; direction: number }>();
|
|
13
|
+
const faces: { vertex: number; volume: number }[] = [];
|
|
14
|
+
const find = (id: number): number => {
|
|
15
|
+
while (parent[id] !== id) { parent[id] = parent[parent[id]]; id = parent[id]; }
|
|
16
|
+
return id;
|
|
17
|
+
};
|
|
18
|
+
for (let i = 0; i < position.count; i++) {
|
|
19
|
+
const point = new THREE.Vector3().fromBufferAttribute(position, i), key = `${point.x},${point.y},${point.z}`;
|
|
20
|
+
let id = weld.get(key);
|
|
21
|
+
if (id === undefined) { id = parent.length; weld.set(key, id); parent.push(id); points.push(point); }
|
|
22
|
+
ids.push(id);
|
|
23
|
+
}
|
|
24
|
+
const cross = new THREE.Vector3(), edge = new THREE.Vector3(), normals = new THREE.Vector3(), n = new THREE.Vector3();
|
|
25
|
+
let valid = true;
|
|
26
|
+
for (let i = 0; i < index.count; i += 3) {
|
|
27
|
+
const raw = [index.getX(i), index.getX(i + 1), index.getX(i + 2)], vertices = raw.map(vertex => ids[vertex]);
|
|
28
|
+
const [a, b, c] = vertices.map(vertex => points[vertex]);
|
|
29
|
+
cross.copy(b).sub(a).cross(edge.copy(c).sub(a));
|
|
30
|
+
if (cross.lengthSq() < 1e-20) continue;
|
|
31
|
+
normals.set(0, 0, 0);
|
|
32
|
+
for (const vertex of raw) normals.add(n.fromBufferAttribute(normal, vertex));
|
|
33
|
+
if (cross.dot(normals) < -1e-14) valid = false;
|
|
34
|
+
for (let j = 0; j < 3; j++) {
|
|
35
|
+
const a = vertices[j], b = vertices[(j + 1) % 3], key = `${Math.min(a, b)}:${Math.max(a, b)}`;
|
|
36
|
+
const count = edges.get(key) ?? { count: 0, direction: 0 };
|
|
37
|
+
count.count++; count.direction += a < b ? 1 : -1; edges.set(key, count);
|
|
38
|
+
parent[find(a)] = find(b);
|
|
39
|
+
}
|
|
40
|
+
faces.push({ vertex: vertices[0], volume: a.dot(cross.copy(b).cross(c)) / 6 });
|
|
41
|
+
}
|
|
42
|
+
const volumes = new Map<number, number>();
|
|
43
|
+
for (const face of faces) { const id = find(face.vertex); volumes.set(id, (volumes.get(id) ?? 0) + face.volume); }
|
|
44
|
+
valid &&= faces.length > 0 && [...edges.values()].every(edge => edge.count === 2 && edge.direction === 0)
|
|
45
|
+
&& [...volumes.values()].every(volume => volume > 1e-12);
|
|
46
|
+
closed.set(geometry, valid);
|
|
47
|
+
return valid;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Whitelist opaque solids; thin/open/transmissive parts retain their original side. */
|
|
51
|
+
export function enableOpaqueBackfaces(surface: string, geometry: THREE.BufferGeometry,
|
|
52
|
+
material: THREE.MeshPhysicalMaterial, representation: "array" | "full") {
|
|
53
|
+
const candidate = surface === "Index_Inlay" || surface === "Optical_Diffuser"
|
|
54
|
+
|| representation === "array" && surface === "Ivory_Edges"
|
|
55
|
+
|| representation === "full" && surface === "Titanium_Fasteners";
|
|
56
|
+
if (!candidate || material.transparent || material.transmission > 0 || material.opacity !== 1
|
|
57
|
+
|| material.blending !== THREE.NormalBlending || !material.depthWrite
|
|
58
|
+
|| material.side !== THREE.DoubleSide && material.side !== THREE.FrontSide
|
|
59
|
+
|| !closedOutwardSurface(geometry)) return false;
|
|
60
|
+
// Three normally derives shadowSide from side. Preserve the authored shadow.
|
|
61
|
+
material.shadowSide ??= material.side === THREE.DoubleSide ? THREE.DoubleSide : THREE.BackSide;
|
|
62
|
+
if (material.side !== THREE.FrontSide) { material.side = THREE.FrontSide; material.needsUpdate = true; }
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
export type ProgramPreparationStatus = "pending" | "compiling" | "ready" | "unsupported" | "cancelled" | "failed";
|
|
4
|
+
type PreparedProgram = { program: WebGLProgram; getUniforms(): unknown; getAttributes(): unknown };
|
|
5
|
+
type Prepared = { objects: THREE.Group[]; materials: Set<THREE.Material> };
|
|
6
|
+
|
|
7
|
+
/** Keep warm material references alive so Three does not release their programs. */
|
|
8
|
+
export class ProgramPreparation {
|
|
9
|
+
private readonly retained: Prepared[] = [];
|
|
10
|
+
private generation = 0;
|
|
11
|
+
private disposed = false;
|
|
12
|
+
private readonly records = new Map<string, { status: ProgramPreparationStatus; materials: number; programs: number; durationMs: number; compileMs: number; reflectionMs: number }>();
|
|
13
|
+
|
|
14
|
+
stats() { return Object.fromEntries([...this.records].map(([key, value]) => [key, { ...value }])); }
|
|
15
|
+
|
|
16
|
+
async prepare(kind: string, sources: readonly THREE.Object3D[], renderer: THREE.WebGLRenderer,
|
|
17
|
+
camera: THREE.Camera, scene: THREE.Scene, colorTarget: THREE.WebGLRenderTarget) {
|
|
18
|
+
if (this.disposed) return;
|
|
19
|
+
const generation = this.generation, began = performance.now();
|
|
20
|
+
const record = { status: "compiling" as ProgramPreparationStatus, materials: 0, programs: 0, durationMs: 0, compileMs: 0, reflectionMs: 0 };
|
|
21
|
+
this.records.set(kind, record);
|
|
22
|
+
const prepared: Prepared = { objects: [new THREE.Group(), new THREE.Group()], materials: new Set() };
|
|
23
|
+
this.retained.push(prepared);
|
|
24
|
+
const copies = new Map<THREE.Material, THREE.Material[]>();
|
|
25
|
+
for (const source of sources) source.traverse(object => {
|
|
26
|
+
if (!(object instanceof THREE.Mesh || object instanceof THREE.Line || object instanceof THREE.Points || object instanceof THREE.Sprite)) return;
|
|
27
|
+
const materials = Array.isArray(object.material) ? object.material : [object.material];
|
|
28
|
+
for (const material of materials) {
|
|
29
|
+
let variants = copies.get(material);
|
|
30
|
+
if (!variants) {
|
|
31
|
+
const sides = material.transparent && material.side === THREE.DoubleSide && !material.forceSinglePass
|
|
32
|
+
? [THREE.BackSide, THREE.FrontSide] : [material.side];
|
|
33
|
+
variants = sides.map(side => {
|
|
34
|
+
const copy = material.clone();
|
|
35
|
+
copy.onBeforeCompile = material.onBeforeCompile;
|
|
36
|
+
copy.customProgramCacheKey = material.customProgramCacheKey;
|
|
37
|
+
copy.side = side; copy.forceSinglePass = true;
|
|
38
|
+
prepared.materials.add(copy);
|
|
39
|
+
return copy;
|
|
40
|
+
});
|
|
41
|
+
copies.set(material, variants);
|
|
42
|
+
}
|
|
43
|
+
for (const material of variants) {
|
|
44
|
+
const copy = object.clone(false) as THREE.Mesh;
|
|
45
|
+
copy.material = material;
|
|
46
|
+
prepared.objects[object.layers.isEnabled(1) ? 1 : 0].add(copy);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
record.materials = prepared.materials.size;
|
|
51
|
+
const cancelled = () => this.disposed || generation !== this.generation || renderer.getContext().isContextLost();
|
|
52
|
+
try {
|
|
53
|
+
const compiled = new Set<PreparedProgram>();
|
|
54
|
+
// Compile under the same output target as each real pass. Restore it before
|
|
55
|
+
// yielding: the visible renderer keeps running while the driver compiles.
|
|
56
|
+
for (let pass = 0; pass < prepared.objects.length; pass++) {
|
|
57
|
+
if (cancelled()) { record.status = "cancelled"; return; }
|
|
58
|
+
const target = renderer.getRenderTarget(), face = renderer.getActiveCubeFace(), mip = renderer.getActiveMipmapLevel();
|
|
59
|
+
try {
|
|
60
|
+
renderer.setRenderTarget(pass === 0 ? colorTarget : null);
|
|
61
|
+
const compileCamera = camera.clone();
|
|
62
|
+
if (pass === 1) compileCamera.layers.set(1);
|
|
63
|
+
const materials = renderer.compile(prepared.objects[pass], compileCamera, scene);
|
|
64
|
+
for (const material of materials) {
|
|
65
|
+
const program = (renderer.properties.get(material) as { currentProgram?: PreparedProgram }).currentProgram;
|
|
66
|
+
if (program) compiled.add(program);
|
|
67
|
+
}
|
|
68
|
+
} finally { renderer.setRenderTarget(target, face, mip); }
|
|
69
|
+
}
|
|
70
|
+
record.compileMs = performance.now() - began;
|
|
71
|
+
const gl = renderer.getContext();
|
|
72
|
+
const extension = renderer.extensions.get("KHR_parallel_shader_compile") as KHR_parallel_shader_compile | null;
|
|
73
|
+
if (!extension) { record.status = "unsupported"; return; }
|
|
74
|
+
// Snapshot program handles, not material.currentProgram: animation may
|
|
75
|
+
// select another variant while this task waits. Polling completion is the
|
|
76
|
+
// nonblocking KHR operation; never query LINK_STATUS or info logs early.
|
|
77
|
+
const programs = [...compiled].map(program => program.program);
|
|
78
|
+
record.programs = programs.length;
|
|
79
|
+
while (!cancelled()) {
|
|
80
|
+
if (performance.now() - began > 30000) throw new Error("Archive program preparation timed out.");
|
|
81
|
+
if (programs.every(program => gl.getProgramParameter(program, extension.COMPLETION_STATUS_KHR))) {
|
|
82
|
+
if (programs.some(program => !gl.getProgramParameter(program, gl.LINK_STATUS)))
|
|
83
|
+
throw new Error("Archive program preparation failed to link a material.");
|
|
84
|
+
// Three's first-use reflection performs synchronous info-log and
|
|
85
|
+
// uniform queries even after linking. Finish that once here, outside
|
|
86
|
+
// the visible draw, yielding between programs to bound each task.
|
|
87
|
+
for (const program of compiled) {
|
|
88
|
+
if (cancelled()) { record.status = "cancelled"; return; }
|
|
89
|
+
const start = performance.now();
|
|
90
|
+
program.getUniforms(); program.getAttributes();
|
|
91
|
+
record.reflectionMs += performance.now() - start;
|
|
92
|
+
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
93
|
+
}
|
|
94
|
+
record.status = cancelled() ? "cancelled" : "ready"; return;
|
|
95
|
+
}
|
|
96
|
+
await new Promise<void>(resolve => setTimeout(resolve, 10));
|
|
97
|
+
}
|
|
98
|
+
record.status = "cancelled";
|
|
99
|
+
} catch (error) {
|
|
100
|
+
record.status = "failed"; throw error;
|
|
101
|
+
} finally { record.durationMs = performance.now() - began; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
invalidate() {
|
|
105
|
+
this.generation++;
|
|
106
|
+
this.records.clear();
|
|
107
|
+
for (const prepared of this.retained) for (const material of prepared.materials) material.dispose();
|
|
108
|
+
this.retained.length = 0;
|
|
109
|
+
}
|
|
110
|
+
dispose() { this.disposed = true; this.invalidate(); }
|
|
111
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
|
|
3
|
+
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
|
|
4
|
+
import { SMAAPass } from "three/addons/postprocessing/SMAAPass.js";
|
|
5
|
+
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
|
|
6
|
+
import { renderDimensions, type RenderQuality } from "./render-quality";
|
|
7
|
+
|
|
8
|
+
export function applyTextureQuality(
|
|
9
|
+
root: THREE.Object3D,
|
|
10
|
+
renderer: THREE.WebGLRenderer,
|
|
11
|
+
quality: RenderQuality,
|
|
12
|
+
) {
|
|
13
|
+
const maximum = Math.min(
|
|
14
|
+
quality.anisotropy,
|
|
15
|
+
renderer.capabilities.getMaxAnisotropy(),
|
|
16
|
+
);
|
|
17
|
+
const textures = new Set<THREE.Texture>();
|
|
18
|
+
root.traverse((object) => {
|
|
19
|
+
if (!(object instanceof THREE.Mesh)) return;
|
|
20
|
+
for (const material of Array.isArray(object.material)
|
|
21
|
+
? object.material
|
|
22
|
+
: [object.material]) {
|
|
23
|
+
for (const value of Object.values(material)) {
|
|
24
|
+
if (value instanceof THREE.Texture && !value.isRenderTargetTexture)
|
|
25
|
+
textures.add(value);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
for (const texture of textures) {
|
|
30
|
+
// Oblique printed text gets a small, local sampling budget; the scene's
|
|
31
|
+
// large baked textures retain the selected performance preset.
|
|
32
|
+
const requested = texture.userData.archivePrint
|
|
33
|
+
? Math.min(16, renderer.capabilities.getMaxAnisotropy()) : maximum;
|
|
34
|
+
if (texture.anisotropy === requested) continue;
|
|
35
|
+
texture.anisotropy = requested;
|
|
36
|
+
texture.needsUpdate = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resizeQuality(
|
|
41
|
+
renderer: THREE.WebGLRenderer,
|
|
42
|
+
composer: EffectComposer,
|
|
43
|
+
host: HTMLElement,
|
|
44
|
+
quality: RenderQuality,
|
|
45
|
+
options: { sharpLabels?: boolean } = {},
|
|
46
|
+
) {
|
|
47
|
+
const width = Math.max(1, host.clientWidth),
|
|
48
|
+
height = Math.max(1, host.clientHeight);
|
|
49
|
+
const stageScale = host.getBoundingClientRect().width / width;
|
|
50
|
+
const dimensions = renderDimensions(
|
|
51
|
+
quality,
|
|
52
|
+
width,
|
|
53
|
+
height,
|
|
54
|
+
stageScale,
|
|
55
|
+
devicePixelRatio,
|
|
56
|
+
renderer.capabilities.maxTextureSize,
|
|
57
|
+
);
|
|
58
|
+
// Only the final drawing buffer and printed labels use the finer grid.
|
|
59
|
+
// Lighting, AO, transmission, depth capture and DOF keep their existing targets.
|
|
60
|
+
const labelDimensions = options.sharpLabels ? renderDimensions(
|
|
61
|
+
{ ...quality, scale: 100, pixelRatio: 2 }, width, height, stageScale, 2,
|
|
62
|
+
renderer.capabilities.maxTextureSize,
|
|
63
|
+
) : dimensions;
|
|
64
|
+
const outputRatio = Math.max(dimensions.ratio, labelDimensions.ratio);
|
|
65
|
+
renderer.setPixelRatio(outputRatio);
|
|
66
|
+
renderer.setSize(width, height);
|
|
67
|
+
composer.setPixelRatio(dimensions.ratio);
|
|
68
|
+
composer.setSize(width, height);
|
|
69
|
+
renderer.transmissionResolutionScale = quality.transmission;
|
|
70
|
+
host.dataset.renderQuality = JSON.stringify({
|
|
71
|
+
...dimensions,
|
|
72
|
+
outputRatio,
|
|
73
|
+
labelRatio: options.sharpLabels ? outputRatio : undefined,
|
|
74
|
+
labelWidth: options.sharpLabels ? Math.max(1, Math.floor(width * outputRatio)) : undefined,
|
|
75
|
+
labelHeight: options.sharpLabels ? Math.max(1, Math.floor(height * outputRatio)) : undefined,
|
|
76
|
+
antialias: quality.antialias,
|
|
77
|
+
transmission: quality.transmission,
|
|
78
|
+
anisotropy: Math.min(
|
|
79
|
+
quality.anisotropy,
|
|
80
|
+
renderer.capabilities.getMaxAnisotropy(),
|
|
81
|
+
),
|
|
82
|
+
});
|
|
83
|
+
return dimensions;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createViewerPipeline(
|
|
87
|
+
renderer: THREE.WebGLRenderer,
|
|
88
|
+
scene: THREE.Scene,
|
|
89
|
+
camera: THREE.Camera,
|
|
90
|
+
) {
|
|
91
|
+
const composer = new EffectComposer(renderer);
|
|
92
|
+
const smaa = new SMAAPass();
|
|
93
|
+
composer.addPass(new RenderPass(scene, camera));
|
|
94
|
+
composer.addPass(smaa);
|
|
95
|
+
composer.addPass(new OutputPass());
|
|
96
|
+
return { composer, smaa };
|
|
97
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
matchingPreset,
|
|
3
|
+
presetLabels,
|
|
4
|
+
type QualityPreset,
|
|
5
|
+
type RenderQuality,
|
|
6
|
+
} from "./render-quality";
|
|
7
|
+
|
|
8
|
+
function select(
|
|
9
|
+
quality: RenderQuality,
|
|
10
|
+
key: keyof RenderQuality,
|
|
11
|
+
label: string,
|
|
12
|
+
hint: string,
|
|
13
|
+
choices: (readonly [string | number, string])[],
|
|
14
|
+
) {
|
|
15
|
+
return `<label class="quality-control"><span>${label}<small>${hint}</small></span><select data-quality="${key}" aria-label="${label}">${choices.map(([value, text]) => `<option value="${value}" ${quality[key] === value ? "selected" : ""}>${text}</option>`).join("")}</select></label>`;
|
|
16
|
+
}
|
|
17
|
+
function range(
|
|
18
|
+
quality: RenderQuality,
|
|
19
|
+
key: "scale" | "depthOfField",
|
|
20
|
+
label: string,
|
|
21
|
+
hint: string,
|
|
22
|
+
min: number,
|
|
23
|
+
max: number,
|
|
24
|
+
) {
|
|
25
|
+
return `<label class="quality-control quality-range"><span>${label}<small>${hint}</small></span><div><input type="range" data-quality="${key}" aria-label="${label}" min="${min}" max="${max}" step="5" value="${quality[key]}"/><output data-quality-output="${key}">${quality[key]}%</output></div></label>`;
|
|
26
|
+
}
|
|
27
|
+
export function qualityMarkup(quality: RenderQuality) {
|
|
28
|
+
const preset = matchingPreset(quality);
|
|
29
|
+
return `<section class="quality-settings" aria-label="画质设置">
|
|
30
|
+
<div class="quality-heading"><h3>RENDER QUALITY <span>渲染画质</span></h3><select id="quality-preset" aria-label="画质预设">${(Object.keys(presetLabels) as QualityPreset[]).map((key) => `<option value="${key}" ${preset === key ? "selected" : ""}>${presetLabels[key]}</option>`).join("")}<option value="custom" disabled ${preset === "custom" ? "selected" : ""}>自定义</option></select></div>
|
|
31
|
+
<p class="quality-summary" id="quality-summary" aria-live="polite"></p>
|
|
32
|
+
<details class="quality-advanced"><summary>精细设置 <span>清晰度 / 材质 / 阴影</span></summary><div class="quality-grid">
|
|
33
|
+
${range(quality, "scale", "渲染比例", "相对屏幕像素,受密度上限限制;高比例改善细线", 50, 200)}
|
|
34
|
+
${select(
|
|
35
|
+
quality,
|
|
36
|
+
"pixelRatio",
|
|
37
|
+
"像素密度上限",
|
|
38
|
+
"控制高密度屏幕的原生像素倍率",
|
|
39
|
+
[1, 1.5, 2, 3].map((v) => [v, `${v}×`]),
|
|
40
|
+
)}
|
|
41
|
+
${select(quality, "antialias", "抗锯齿", "SMAA 平滑模型边缘与后处理结果", [
|
|
42
|
+
["off", "原始"],
|
|
43
|
+
["smaa", "SMAA"],
|
|
44
|
+
])}
|
|
45
|
+
${select(
|
|
46
|
+
quality,
|
|
47
|
+
"anisotropy",
|
|
48
|
+
"纹理过滤",
|
|
49
|
+
"改善倾斜视角下的标签细节",
|
|
50
|
+
[1, 2, 4, 8, 16].map((v) => [v, `${v}×`]),
|
|
51
|
+
)}
|
|
52
|
+
${select(
|
|
53
|
+
quality,
|
|
54
|
+
"transmission",
|
|
55
|
+
"透明材质分辨率 · 阵列",
|
|
56
|
+
"控制阵列盖板折射清晰度;档案架使用 100%",
|
|
57
|
+
[0.25, 0.5, 0.75, 1].map((v) => [v, `${v * 100}%`]),
|
|
58
|
+
)}
|
|
59
|
+
${select(
|
|
60
|
+
quality,
|
|
61
|
+
"shadows",
|
|
62
|
+
"阴影分辨率 · 阵列",
|
|
63
|
+
"更高分辨率保留更细的投影边缘",
|
|
64
|
+
[
|
|
65
|
+
[0, "关闭"],
|
|
66
|
+
[1024, "1024"],
|
|
67
|
+
[2048, "2048"],
|
|
68
|
+
[4096, "4096"],
|
|
69
|
+
],
|
|
70
|
+
)}
|
|
71
|
+
${select(
|
|
72
|
+
quality,
|
|
73
|
+
"aoSamples",
|
|
74
|
+
"环境遮蔽 · 阵列",
|
|
75
|
+
"采样越多,接缝暗部越细腻",
|
|
76
|
+
[
|
|
77
|
+
[0, "关闭"],
|
|
78
|
+
[16, "16 采样"],
|
|
79
|
+
[32, "32 采样"],
|
|
80
|
+
[64, "64 采样"],
|
|
81
|
+
],
|
|
82
|
+
)}
|
|
83
|
+
${select(
|
|
84
|
+
quality,
|
|
85
|
+
"aoResolution",
|
|
86
|
+
"遮蔽分辨率 · 阵列",
|
|
87
|
+
"降低可减轻环境遮蔽的渲染负担",
|
|
88
|
+
[0.5, 0.75, 1].map((v) => [v, `${v * 100}%`]),
|
|
89
|
+
)}
|
|
90
|
+
${range(quality, "depthOfField", "景深强度 · 阵列", "0% 关闭;100% 保留原始镜头虚化;档案架不虚化", 0, 150)}
|
|
91
|
+
</div></details><p class="quality-note">即时生效并自动保存。清晰度与材质设置同步至 360° 查看器。高渲染比例更适合静态观察;缓冲上限为 829 万像素,硬件限制时自动收敛。</p>
|
|
92
|
+
</section>`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function syncQualityUI(quality: RenderQuality) {
|
|
96
|
+
const preset = document.querySelector<HTMLSelectElement>("#quality-preset");
|
|
97
|
+
if (!preset) return;
|
|
98
|
+
preset.value = matchingPreset(quality);
|
|
99
|
+
document
|
|
100
|
+
.querySelectorAll<HTMLInputElement | HTMLSelectElement>("[data-quality]")
|
|
101
|
+
.forEach((control) => {
|
|
102
|
+
const key = control.dataset.quality as keyof RenderQuality;
|
|
103
|
+
control.value = String(quality[key]);
|
|
104
|
+
control.disabled = key === "aoResolution" && quality.aoSamples === 0;
|
|
105
|
+
});
|
|
106
|
+
document
|
|
107
|
+
.querySelectorAll<HTMLOutputElement>("[data-quality-output]")
|
|
108
|
+
.forEach((output) => {
|
|
109
|
+
output.value = `${quality[output.dataset.qualityOutput as keyof RenderQuality]}%`;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/** Rendering controls are independent of lighting, materials and animation. */
|
|
2
|
+
export type RenderQuality = {
|
|
3
|
+
scale: number;
|
|
4
|
+
pixelRatio: number;
|
|
5
|
+
antialias: "off" | "smaa";
|
|
6
|
+
shadows: number;
|
|
7
|
+
aoSamples: number;
|
|
8
|
+
aoResolution: number;
|
|
9
|
+
depthOfField: number;
|
|
10
|
+
transmission: number;
|
|
11
|
+
anisotropy: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const qualityPresets = {
|
|
15
|
+
performance: {
|
|
16
|
+
// Keep thin cassette rims at native CSS resolution. Save work through
|
|
17
|
+
// lighting/postprocessing instead of enlarging an undersampled image.
|
|
18
|
+
scale: 100,
|
|
19
|
+
pixelRatio: 1,
|
|
20
|
+
antialias: "smaa",
|
|
21
|
+
shadows: 1024,
|
|
22
|
+
aoSamples: 0,
|
|
23
|
+
aoResolution: 0.5,
|
|
24
|
+
depthOfField: 0,
|
|
25
|
+
transmission: 0.5,
|
|
26
|
+
anisotropy: 4,
|
|
27
|
+
},
|
|
28
|
+
original: {
|
|
29
|
+
scale: 100,
|
|
30
|
+
pixelRatio: 1.5,
|
|
31
|
+
antialias: "off",
|
|
32
|
+
shadows: 2048,
|
|
33
|
+
aoSamples: 32,
|
|
34
|
+
aoResolution: 1,
|
|
35
|
+
depthOfField: 100,
|
|
36
|
+
transmission: 1,
|
|
37
|
+
anisotropy: 16,
|
|
38
|
+
},
|
|
39
|
+
high: {
|
|
40
|
+
scale: 125,
|
|
41
|
+
pixelRatio: 2,
|
|
42
|
+
antialias: "smaa",
|
|
43
|
+
shadows: 4096,
|
|
44
|
+
aoSamples: 32,
|
|
45
|
+
aoResolution: 1,
|
|
46
|
+
depthOfField: 100,
|
|
47
|
+
transmission: 1,
|
|
48
|
+
anisotropy: 16,
|
|
49
|
+
},
|
|
50
|
+
ultra: {
|
|
51
|
+
scale: 150,
|
|
52
|
+
pixelRatio: 2,
|
|
53
|
+
antialias: "smaa",
|
|
54
|
+
shadows: 4096,
|
|
55
|
+
aoSamples: 64,
|
|
56
|
+
aoResolution: 1,
|
|
57
|
+
depthOfField: 100,
|
|
58
|
+
transmission: 1,
|
|
59
|
+
anisotropy: 16,
|
|
60
|
+
},
|
|
61
|
+
} as const satisfies Record<string, RenderQuality>;
|
|
62
|
+
export type QualityPreset = keyof typeof qualityPresets;
|
|
63
|
+
export const presetLabels: Record<QualityPreset, string> = {
|
|
64
|
+
performance: "性能",
|
|
65
|
+
original: "原始",
|
|
66
|
+
high: "高",
|
|
67
|
+
ultra: "极高",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const member = <T>(value: unknown, choices: readonly T[], fallback: T): T =>
|
|
71
|
+
choices.includes(value as T) ? (value as T) : fallback;
|
|
72
|
+
const range = (
|
|
73
|
+
value: unknown,
|
|
74
|
+
min: number,
|
|
75
|
+
max: number,
|
|
76
|
+
step: number,
|
|
77
|
+
fallback: number,
|
|
78
|
+
) =>
|
|
79
|
+
typeof value === "number" && Number.isFinite(value)
|
|
80
|
+
? Math.min(max, Math.max(min, Math.round(value / step) * step))
|
|
81
|
+
: fallback;
|
|
82
|
+
|
|
83
|
+
export function normalizeQuality(
|
|
84
|
+
value: unknown,
|
|
85
|
+
legacyHigh = true,
|
|
86
|
+
): RenderQuality {
|
|
87
|
+
const base = qualityPresets.original;
|
|
88
|
+
const v =
|
|
89
|
+
value && typeof value === "object"
|
|
90
|
+
? (value as Record<string, unknown>)
|
|
91
|
+
: {};
|
|
92
|
+
// Older low quality retained full resolution, shadows and transmission.
|
|
93
|
+
const fallback = legacyHigh
|
|
94
|
+
? base
|
|
95
|
+
: { ...base, pixelRatio: 1, aoSamples: 0, depthOfField: 0 };
|
|
96
|
+
return {
|
|
97
|
+
scale: range(v.scale, 50, 200, 5, fallback.scale),
|
|
98
|
+
pixelRatio: member(v.pixelRatio, [1, 1.5, 2, 3], fallback.pixelRatio),
|
|
99
|
+
antialias: member(
|
|
100
|
+
v.antialias,
|
|
101
|
+
["off", "smaa"] as const,
|
|
102
|
+
fallback.antialias,
|
|
103
|
+
),
|
|
104
|
+
shadows: member(v.shadows, [0, 1024, 2048, 4096], fallback.shadows),
|
|
105
|
+
aoSamples: member(v.aoSamples, [0, 16, 32, 64], fallback.aoSamples),
|
|
106
|
+
aoResolution: member(v.aoResolution, [0.5, 0.75, 1], fallback.aoResolution),
|
|
107
|
+
depthOfField: range(v.depthOfField, 0, 150, 5, fallback.depthOfField),
|
|
108
|
+
transmission: member(
|
|
109
|
+
v.transmission,
|
|
110
|
+
[0.25, 0.5, 0.75, 1],
|
|
111
|
+
fallback.transmission,
|
|
112
|
+
),
|
|
113
|
+
anisotropy: member(v.anisotropy, [1, 2, 4, 8, 16], fallback.anisotropy),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Upgrade the old complete performance preset without changing custom values. */
|
|
118
|
+
export function restoreQuality(value: unknown): RenderQuality {
|
|
119
|
+
const legacyPerformance = { ...qualityPresets.performance, scale: 80, antialias: "off" };
|
|
120
|
+
if (value && typeof value === "object" &&
|
|
121
|
+
Object.entries(legacyPerformance).every(([key, expected]) =>
|
|
122
|
+
(value as Record<string, unknown>)[key] === expected)) {
|
|
123
|
+
return { ...qualityPresets.performance };
|
|
124
|
+
}
|
|
125
|
+
return normalizeQuality(value);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function matchingPreset(
|
|
129
|
+
quality: RenderQuality,
|
|
130
|
+
): QualityPreset | "custom" {
|
|
131
|
+
return (
|
|
132
|
+
(Object.keys(qualityPresets) as QualityPreset[]).find((key) =>
|
|
133
|
+
Object.entries(qualityPresets[key]).every(
|
|
134
|
+
([field, value]) => quality[field as keyof RenderQuality] === value,
|
|
135
|
+
),
|
|
136
|
+
) ?? "custom"
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function renderDimensions(
|
|
141
|
+
quality: RenderQuality,
|
|
142
|
+
width: number,
|
|
143
|
+
height: number,
|
|
144
|
+
stageScale: number,
|
|
145
|
+
deviceRatio: number,
|
|
146
|
+
maxTextureSize: number,
|
|
147
|
+
) {
|
|
148
|
+
const requested =
|
|
149
|
+
(Math.min(deviceRatio, quality.pixelRatio) * stageScale * quality.scale) /
|
|
150
|
+
100;
|
|
151
|
+
// Bound all full-resolution postprocessing targets to 8.3 MP and device limits.
|
|
152
|
+
const ratio = Math.min(
|
|
153
|
+
requested,
|
|
154
|
+
Math.sqrt(8_294_400 / Math.max(1, width * height)),
|
|
155
|
+
maxTextureSize / Math.max(1, width, height),
|
|
156
|
+
);
|
|
157
|
+
return {
|
|
158
|
+
ratio,
|
|
159
|
+
width: Math.max(1, Math.floor(width * ratio)),
|
|
160
|
+
height: Math.max(1, Math.floor(height * ratio)),
|
|
161
|
+
limited: ratio < requested - 0.0001,
|
|
162
|
+
};
|
|
163
|
+
}
|