Compare commits

..

6 Commits

Author SHA1 Message Date
github-actions[bot] 7ab6797513 Bump version to 2026.8.0-alpha.0 2026-08-15 14:39:25 +00:00
おさむのひと 988e3c32a3 fix: shipping-misskey-changeの調整 (#17852)
* fix: shipping-misskey-changeの過剰な反応を調整

* fix review

* fix

* fix

* fix
2026-08-05 09:31:13 +09:00
anatawa12 b812ddbbf2 make calculateMuteStatus return array with checkOnly = true and split calculateMuteStatus into word mute part and built-in soft mute part (#17829)
* refactor: make calculateMuteStatus return array even when checkOnly = true

* refactor: simplify calculateMuteStatus result type

* refactor: split calculateMuteStatus into word mute part and built-in part
2026-08-04 17:38:18 +09:00
anatawa12 1c82afbc23 refactor: linearlize findMaxPrefixLength for performance (#17853) 2026-08-04 15:26:41 +09:00
4ster1sk a6959a53a9 fix(backend): admin/queue/statsの権限をread:admin:queueに修正 (#17850)
* fix(backend): admin/queue/statsの権限をread:admin:queueに修正

admin/queue/statsのkindがread:admin:emojiになっており、

read:admin:emojiスコープのアプリがキュー統計を取得でき、read:admin:queueスコープでは

取得できなかったためkindをread:admin:queueに修正。

* fix: run build-misskey-js-with-types
2026-08-04 11:18:37 +09:00
かっこかり e67df72198 fix: review fixes for 2026.7.0 (#17832)
* docs(frontend): MkInputのパディング計算コメントを実装に合わせて修正

ResizeObserverによる監視に変更済みで「定期的に計算する」は事実と異なるため。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(frontend): MkSelectのパディング計算コメントを実装に合わせて修正

ResizeObserverによる監視に変更済みで「定期的に計算する」は事実と異なるため。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(frontend): rippleディレクティブの未使用WeakMapを削除

イベント解除はAbortControllerに移行済みで、handlersはどこからも参照されていないため。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(frontend): use-noteの非reactiveな導出値からcomputedを外す

rawNote / appearNote / $i.id はcomposableの生存期間中に変化しないため、
isMyRenote / parsed / urls / isLong / canRenote はcomputedにする必要がない。
preferの変更に追従する必要があるshowTickerのみcomputedのまま残す。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(frontend): use-noteのreact()の引数名をcreateReactionMockに変更

mock時にリアクション作成の代わりに呼ばれるコールバックであることが
customCallbackという名前からは読み取れないため。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(frontend): signup重複ユーザー名テストの規約同意前チェックを復活

Playwright移行時に、規約に同意する前は続けるボタンがdisabledであることの
検証が抜け落ちていたため。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(frontend/test): タイムアウトの理由コメントを実際の指定箇所へ移動

タイムアウトを指定しているのはcloseUserSetupDialogの既定値側であり、
呼び出し側にコメントだけが残って浮いていたため。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(backend): LogNormalizerの文字列切り詰めを線形走査に変更

二分探索は各ステップでslice + Buffer.byteLengthを行うため全体でO(n log n)
だった。コードポイント単位で先頭から積み上げればO(n)で済み、サロゲート対の
巻き戻し処理も不要になる。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(backend): AiServiceをSensitiveMediaDetectionServiceにリネーム

NSFW推論を本体で行わなくなり、外部サービス (sensitive-detector) への
アダプタになったため、AiServiceという名前が実態と合わなくなっていた。
ログドメインも `ai` から `sensitive-media-detection` に変更する。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* clean up [ci skip]

* test(backend): LogNormalizerの文字列切り詰めに境界ケースのテストを追加

線形走査への変更に対し、2/3/4バイト文字が切り詰め境界に来る場合と、
接尾辞が上限に収まらない場合の挙動を検証する。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(backend): LogNormalizerの用語を「サロゲート対」から「サロゲートペア」に統一

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(frontend): use-noteのshowTickerからcomputedを外す

prefer.s は静的な値でreactiveではない (reactiveな設定値は prefer.r) ため、
computedにしても設定変更に追従せず、単なるキャッシュにしかなっていなかった。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(frontend): use-noteの不要なリアクティブラッパーを外す

- hideByPlugin: プラグイン割り込み処理の内部で確定し以降変化しないためref不要
- hardMuted: mutedと違い解除操作がなく書き換わらないためref不要
- pleaseLoginContext: hostは定数、appearNoteは非reactiveなのでcomputed不要

$appearNote は useNoteCapture が reactive() で返す真にリアクティブな値、
muted はテンプレートから解除操作で書き換わるため、いずれもそのまま残す。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Revert "test(backend): LogNormalizerの文字列切り詰めに境界ケースのテストを追加"

This reverts commit 7bd6152cca.

* Revert "perf(backend): LogNormalizerの文字列切り詰めを線形走査に変更"

This reverts commit 3ec88c35aa.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 09:50:11 +09:00
1150 changed files with 1000 additions and 35491 deletions
@@ -1,6 +1,6 @@
---
name: shipping-misskey-change
description: Use at every finish moment of a Misskey change, before committing, opening a PR, merging, or handing work back, especially when validation, SPDX, locale safety, migrations, misskey-js generation, or CHANGELOG checks may apply.
description: Use at every finish moment of a Misskey change, before committing, opening a PR, merging, or handing work back, especially when proportional validation, SPDX, locale safety, migrations, or misskey-js generation may apply.
---
# shipping-misskey-change
+1 -1
View File
@@ -18,7 +18,7 @@
| `.claude/` 内のパス | 上流パス | 上流由来 | Misskey での改変 |
|---|---|---|---|
| `skills/context-budget/SKILL.md` | `skills/context-budget/SKILL.md` | ECC | description を日本語化、Misskey 固有メモを追記 |
| `commands/harness-audit.md` | `commands/harness-audit.md` | ECC | scripts 依存の自動採点を、Claude が `pnpm`/`git`/`grep` で手動採点する版に書き換え。Misskey 固有の評価軸 (SPDX / endpoint-list / migration / locales) を組み込み |
| `commands/harness-audit.md` | `commands/harness-audit.md` | ECC | scripts 依存の自動採点を、repository-native command と SPDX checker で採点する版に書き換え。Misskey 固有の評価軸 (SPDX / endpoint-list / migration / locales) を組み込み |
| `commands/quality-gate.md` | `commands/quality-gate.md` | ECC | 言語自動判定を排除し Misskey 固定 pipeline (`pnpm` + tsc + ESLint + Vitest) に。Prettier/Biome フェーズを削除 |
### MIT License (full text)
+2 -1
View File
@@ -10,7 +10,8 @@ frontmatter (`name` + `description` + `tools`) は、Claude が **自動でエ
レビュー面を増やしすぎないよう、役割を分ける:
- **この `.claude/agents/` の 2 つ**: backend endpoint / Vue SFC の **Misskey 固有・機械的チェック** (endpoint-list 登録漏れ・misskey-js 再生成漏れ・ja-JP.yml 限定・SPDX 形式・Storybook 併設 等)。別コンテキストで差分を機械走査する価値がある領域に限定する
- **この `.claude/agents/` の 2 つ**: backend endpoint / Vue SFC の **Misskey 固有・機械的チェック** (endpoint-list 登録漏れ・misskey-js 再生成漏れ・ja-JP.yml 限定・Storybook 併設 等)。
別コンテキストで差分を機械走査する価値がある領域に限定する
- **`pr-review-toolkit` プラグイン (code-reviewer / silent-failure-hunter 等)**: 言語非依存の一般的なコード品質・バグ・設計レビュー。Misskey 固有規約は見ない
- **`working-on-*` skill の checklist**: コードを **書いている最中** の自己チェック (レビュー専用ではなく実装ガイド)
+9 -33
View File
@@ -1,6 +1,6 @@
---
name: misskey-api-reviewer
description: Misskey backend の REST API エンドポイント (packages/backend/src/server/api/endpoints/) 追加・変更を機械レビューする。endpoint-list 登録漏れ・misskey-js 再生成漏れ・meta/paramDef/UUID/SPDX を検査。backend API を変更した PR レビューで呼ぶ。
description: Misskey backend の REST API エンドポイント (packages/backend/src/server/api/endpoints/) 追加・変更を機械レビューする。endpoint-list 登録漏れ・misskey-js 再生成漏れ・meta/paramDef/UUID を検査。backend API を変更した PR レビューで呼ぶ。
tools: Read, Grep, Glob, Bash
---
@@ -30,26 +30,12 @@ BASE=$(git merge-base origin/develop HEAD)
- `packages/backend/src/server/api/endpoint-list.ts`
- `packages/backend/test/e2e/**` (とくに `endpoints.ts``<area>.ts`)
- `packages/misskey-js/src/autogen/**`
- `CHANGELOG.md`
差分対象が空なら「レビュー対象の API エンドポイント変更なし」と短く報告して終了。
## チェックリスト
### 1. SPDX ヘッダー (Critical)
新規 `.ts` ファイル冒頭に以下があるか:
```
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
```
欠落すると CI の `spdx` ジョブが落ちる。
### 2. `meta` の必須・推奨フィールド (Major)
### 1. `meta` の必須・推奨フィールド (Major)
[endpoints.ts の型定義](../../packages/backend/src/server/api/endpoints.ts) を真とする。
@@ -63,7 +49,7 @@ BASE=$(git merge-base origin/develop HEAD)
- `res`: JSON Schema または `ref: '<EntityName>'`。各プロパティに `optional` / `nullable`**明示** されているか。
- `requireFile` / `secure` / `allowGet` / `cacheSec` / `description`: 該当するエンドポイントで使い分けているか。
### 3. `meta.errors` の UUID 検証 (Critical)
### 2. `meta.errors` の UUID 検証 (Critical)
`errors[*].id` が:
@@ -78,14 +64,14 @@ grep -rn "id: '<生成された UUID>'" packages/backend/src/server/api/endpoint
新規エンドポイントの全 `id` を抽出して衝突を確認する。
### 4. `paramDef` (Major)
### 3. `paramDef` (Major)
- JSON Schema 形式 (`type: 'object'`, `properties`, `required`)
- ID 文字列は `format: 'misskey:id'`
- `required` 配列で必須プロパティを明示
- `as const` または `as const satisfies Schema` で型推論を効かせる (既存実装は前者多数。`as const` 自体が無く `Schema` 型注釈もない場合のみ指摘)
### 5. エンドポイント実装本体 (Major)
### 4. エンドポイント実装本体 (Major)
- `Endpoint<typeof meta, typeof paramDef>` を継承しているか。
- `@Injectable()` デコレータ + `export default class` 形式か (`// eslint-disable-line import/no-default-export` が必要)。
@@ -94,7 +80,7 @@ grep -rn "id: '<生成された UUID>'" packages/backend/src/server/api/endpoint
- 防御的アサーション・「起きるはずがない」内部不整合・テスト用 ENV ガード等の **想定外フェイルファスト**`throw new Error('...')` で構わない。既存実装でも `admin/reset-password.ts` などが採用しているパターン (例: `cannot reset password of root`)。`meta.errors` に対応がない `throw new Error` を一律で指摘しない。
- 同期 `throw` は許容。非同期処理での例外伝搬を確認する。
### 6. ★ `endpoint-list.ts` への登録 (Critical)
### 5. ★ `endpoint-list.ts` への登録 (Critical)
最も忘れやすい。**忘れると 404**。[endpoint-list.ts](../../packages/backend/src/server/api/endpoint-list.ts) に 1 行追加されているか:
@@ -110,7 +96,7 @@ grep -F "'<category>/<name>'" packages/backend/src/server/api/endpoint-list.ts
**並び順の補足**: ファイル全体は厳密なアルファベット順では並んでおらず、同カテゴリ内 (`admin/queue/*` など) でも追加された経緯どおりの順になっている箇所が多い。**順序逸脱は指摘根拠にしない** (誤検知の元)。「行が存在するか」のみを Critical 観点として扱う。
### 7. `misskey-js` 再生成 (Critical)
### 6. `misskey-js` 再生成 (Critical)
`meta` / `paramDef` / `res` を変更したら、PR / ブランチに `packages/misskey-js/src/autogen/` 配下の差分が含まれているか確認する:
@@ -121,22 +107,12 @@ git diff --name-only "$BASE"...HEAD -- packages/misskey-js/src/autogen/
差分ゼロなら `pnpm build-misskey-js-with-types` の実行漏れ。CI の `check-misskey-js-autogen` ワークフローで必ず落ちるため Critical 扱い。
### 8. e2e テスト (Major)
### 7. e2e テスト (Major)
[test/e2e/endpoints.ts](../../packages/backend/test/e2e/endpoints.ts) または `test/e2e/<area>.ts` (`note.ts`, `users.ts` 等) 配下に、対応する `api('<category>/<name>', ...)` 呼び出しを含む `test(...)` ケースが追加されているか確認する。複雑な分岐 (権限チェック・エラーケース) の網羅も確認する。
**describe ラベルの形式は問わない**: 既存テストは `describe('Note', () => { test('投稿できる', ...) })` のように人間可読ラベルで構造化されており、`<category>/<name>` 形式の describe は使われていない。describe 名の規約違反としては指摘しない。
### 9. CHANGELOG エントリ (Minor)
ユーザー影響がある (新エンドポイント / 既存挙動変更) 場合、`CHANGELOG.md``## Unreleased``### Server` に 1 行追加されているか確認する。
```
- Feat: /api/<category>/<name> を追加
```
純粋な内部リファクタなら不要。
## 出力形式
優先度別に以下のフォーマットで出力する。
@@ -166,4 +142,4 @@ git diff --name-only "$BASE"...HEAD -- packages/misskey-js/src/autogen/
- [endpoint-base.ts (Endpoint 基底クラス)](../../packages/backend/src/server/api/endpoint-base.ts)
- [error.ts (ApiError)](../../packages/backend/src/server/api/error.ts)
- [test/e2e/endpoints.ts](../../packages/backend/test/e2e/endpoints.ts)
- [AGENTS.md](../../AGENTS.md) — SPDX / マイグレーション履歴 / CHANGELOG 書式などの最低限ルール (Codex / Copilot と共通)
- [AGENTS.md](../../AGENTS.md) — 共通の安全規約と検証方針
+13 -38
View File
@@ -1,6 +1,6 @@
---
name: vue-component-reviewer
description: Misskey frontend の Vue 3 SFC (packages/frontend/src/components/ / pages/ の *.vue) 変更を機械レビューする。SPDX (HTML コメント)・Mk* 命名・i18n.ts/tsx・SCSS 変数・os.* 経由・a11y・Storybook 併設 (*.stories.impl.ts) を検査。frontend の .vue を変更した PR レビューで呼ぶ。
description: Misskey frontend の Vue 3 SFC (packages/frontend/src/components/ / pages/ の *.vue) 変更を機械レビューする。Mk* 命名・i18n.ts/tsx・SCSS 変数・os.* 経由・a11y・Storybook 併設 (*.stories.impl.ts) を検査。frontend の .vue を変更した PR レビューで呼ぶ。
tools: Read, Grep, Glob, Bash
---
@@ -29,40 +29,26 @@ BASE=$(git merge-base origin/develop HEAD)
- `locales/*.yml` (とくに `ja-JP.yml` 以外の変更は即 Critical)
- `packages/frontend/src/components/**/*.stories.impl.ts`
- `CHANGELOG.md`
差分対象が空なら「レビュー対象の Vue コンポーネント変更なし」と短く報告して終了。
## チェックリスト
### 1. SPDX ヘッダー (Critical)
`.vue` ファイル冒頭は **HTML コメント形式** で必須:
```html
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
```
`/* ... */` (TS 形式) は禁止 (CI の `spdx` ジョブはコメント形式ではなく SPDX 文字列の有無のみを検査するため、形式が違っても CI は通るが、規約違反として指摘する)。形式の根拠は references/knowledge 側を参照。
### 2. 命名規約 (Major)
### 1. 命名規約 (Major)
- 共有 / 再利用コンポーネント (`packages/frontend/src/components/` 配下、サブディレクトリ含む) は `Mk` プレフィックス必須 (例: `MkButton.vue`, `global/MkAvatar.vue`, `grid/MkGrid.vue`)。
- ページ固有のものは `pages/` 配下に置き、`Mk` プレフィックスは不要。
**補足:** `<script setup>` SFC は named export を持たないため、「ファイル名と export 名の一致」を機械的に検査することはできない。SFC のデフォルトエクスポートはコンパイラ生成なので、ファイル名規約のみを基準にする。
### 3. `<script>` タグ (Major)
### 2. `<script>` タグ (Major)
- `<script lang="ts" setup>` または `<script setup lang="ts">` のどちらでもよい (既存コードは多数派が前者だが、後者も `MkThemePreview.vue` 等で使われている)。属性順は指摘しない。`lang="ts"`**無い** ものは指摘する。
- 型ジェネリックが必要なら `generic="T extends ..."` 属性を加える (順序問わず)。
- `defineProps<{ ... }>()` / `defineEmits<{ ... }>()`**type-only** 形式。runtime の object 形式 (`defineProps({ ... })`) は使わない。
- Options API (`export default { data() { ... } }`) は禁止。
### 4. i18n の使い分け (Critical)
### 3. i18n の使い分け (Critical)
- 文字列リテラルの直書き禁止 (テンプレート / JS 両方)。
- 引数なし: `i18n.ts.<path>` (例: `i18n.ts.deleted`)。
@@ -77,7 +63,7 @@ BASE=$(git merge-base origin/develop HEAD)
git diff --name-only "$BASE"...HEAD -- 'locales/*.yml' | grep -v 'ja-JP.yml'
```
### 5. スタイル (Major)
### 4. スタイル (Major)
- `<style lang="scss" module>` を既定とし、`:class="$style.foo"` で参照する。
- 新規で `<style scoped>` (module なし) は使わない (legacy)。
@@ -94,7 +80,7 @@ git diff "$BASE"...HEAD -- 'packages/frontend/src/**/*.vue' \
| grep -E '^\+' | grep -E '#[0-9a-fA-F]{3,8}\b|rgba?\('
```
### 6. UI 操作は `os.*` 経由 (Critical)
### 5. UI 操作は `os.*` 経由 (Critical)
- 直接の `alert()` / `confirm()` / `window.prompt()` / `window.alert()` は禁止。
- `os.alert` / `os.confirm` / `os.popup` / `os.toast` / `os.popupMenu` / `os.contextMenu` / `os.form` / `os.apiWithDialog` を使う ([os.ts](../../packages/frontend/src/os.ts) 参照)。
@@ -107,14 +93,14 @@ git diff "$BASE"...HEAD -- 'packages/frontend/src/**/*.vue' \
| grep -E '^\+' | grep -E '\b(alert|confirm|prompt)\s*\('
```
### 7. アクセシビリティ (Major)
### 6. アクセシビリティ (Major)
- クリック可能要素は `<button>` か、`role="button"` + `tabindex="0"` + キーボードハンドラ (`@keydown.enter` 等) を実装する。
- 装飾以外の `<div @click>` で a11y 配慮がないものは指摘する。
- フォーム要素には対応する `<label>` または `aria-label` を付ける。
- `:disabled` バインドや `aria-disabled` の整合性を確認する。
### 8. Storybook 併設 (Major)
### 7. Storybook 併設 (Major)
- 共有 `Mk*` コンポーネントを新規追加した場合、`Mk<Name>.stories.impl.ts` が同階層に併設されているか (サブディレクトリ含む。例: `components/global/MkAvatar.stories.impl.ts`, `components/grid/MkGrid.stories.impl.ts`)。
- **ファイル名は `.stories.impl.ts` 固定** (`.stories.ts` は生成物なので手編集・コミット不可)。
@@ -130,31 +116,20 @@ git diff --name-only --diff-filter=A "$BASE"...HEAD -- \
| xargs -I {} sh -c 'test -f {} || echo "missing: {}"'
```
### 9. アイコン (Minor)
### 8. アイコン (Minor)
- アイコンは Tabler icons クラス (`<i class="ti ti-info-circle">` 等) を使う。
- インライン SVG や別アイコンセットは原則使わない (既存パターンに合わせる)。
### 10. CHANGELOG エントリ (Minor)
ユーザー影響がある変更なら、`CHANGELOG.md``## Unreleased``### Client` に 1 行追加されているか確認する。
```
- Enhance: <component> の <挙動> を改善
- Fix: <component> の <不具合> を修正
```
純粋な内部リファクタなら不要。
## 出力形式
優先度別に以下のフォーマットで出力する。
```
## 🔴 Critical
- packages/frontend/src/components/MkFoo.vue:1
SPDX ヘッダーが HTML コメント形式ではなく TS 形式になっている。
`<!-- ... -->` で書き直すこと。
- packages/frontend/src/components/MkFoo.vue:42
ブラウザ組み込みの `alert()` を直接呼んでいる。
`os.alert()` を使用すること。
## 🟡 Major
- ...
@@ -175,4 +150,4 @@ git diff --name-only --diff-filter=A "$BASE"...HEAD -- \
- [MkButton.vue](../../packages/frontend/src/components/MkButton.vue)
- [MkInput.vue](../../packages/frontend/src/components/MkInput.vue) — generic SFC 例
- [MkButton.stories.impl.ts](../../packages/frontend/src/components/MkButton.stories.impl.ts) — Storybook 雛形
- [AGENTS.md](../../AGENTS.md) — SPDX / locales 編集制限 / CHANGELOG 書式などの最低限ルール (Codex / Copilot と共通)
- [AGENTS.md](../../AGENTS.md) — 共通の安全規約と検証方針
+13 -21
View File
@@ -12,7 +12,9 @@ upstream path: commands/harness-audit.md
upstream license: MIT — https://github.com/affaan-m/everything-claude-code/blob/main/LICENSE
project-level notice: see .claude/THIRD_PARTY_LICENSES.md (Misskey 内サードパーティ一覧 + MIT 全文)
Imported into Misskey .claude/ on 2026-05-10. The 7-category rubric and output contract are derived from the upstream ECC version (MIT). The runtime layer was substantially reimplemented for Misskey: the upstream relies on scripts/harness-audit.js to mechanically score, while this version asks Claude to score directly with pnpm/git/grep, and adds Misskey-specific evaluation axes (SPDX coverage / endpoint-list 登録漏れ / migration 順序 / ja-JP.yml 整合).
Imported into Misskey .claude/ on 2026-05-10.
The 7-category rubric and output contract are derived from the upstream ECC version (MIT).
The runtime layer was substantially reimplemented for Misskey: the upstream relies on scripts/harness-audit.js to mechanically score, while this version uses repository-native commands and the SPDX checker, and adds Misskey-specific evaluation axes (SPDX coverage / endpoint-list 登録漏れ / migration 順序 / ja-JP.yml 整合).
note: 元 ECC 版は scripts/harness-audit.js (専用 Node スクリプト) で機械採点していたが、Misskey は ECC plugin runtime に依存しない方針なので、Claude が直接ファイルを読んで採点する手動運用版に書き換えた。Misskey 固有の重要観点 (SPDX 適用率 / endpoint-list 登録漏れ / migration 順序 / ja-JP.yml 整合) を評価軸として明示的に組み込んでいる。
-->
@@ -33,10 +35,10 @@ Misskey リポジトリの `.claude/` 構成を 7 カテゴリで採点し、改
| --- | --- | --- |
| 1 | Tool Coverage | skill / agent / command の数、欠けているワークフロー段、重複なし |
| 2 | Context Efficiency | frontmatter description の冗長度、SKILL.md の長さ分布、重複情報、CLAUDE.md の肥大化 |
| 3 | Quality Gates | Stop / PreToolUse / PostToolUse hook の整備、`/quality-gate` 等の完了前ゲートの有無、自動 lint/typecheck |
| 3 | Quality Gates | 変更ファイル lint、`/quality-gate`、変更別 test / typecheck、CI gate との整合 |
| 4 | Memory Persistence | `.claude/skills/*/SKILL.md``references/` の同期状態を評価。プロジェクト側 `.claude/memory/` は未採用方針 (auto-memory はユーザーホーム側で自動運用) のため、ここを採点起点にせず既定 5/10 から開始する |
| 5 | Eval Coverage | `working-on-backend` / `working-on-frontend` の testing リファレンス (backend-testing.md / frontend-testing.md) の網羅、Misskey 固有の e2e/fed/Storybook/Playwright 適用ガイド |
| 6 | Security Guardrails | SPDX 規約適用、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 |
| 6 | Security Guardrails | SPDX checker、migration 不変性ルール、ja-JP.yml 限定編集ルール、secrets 検出 |
| 7 | Cost Efficiency | enabledPlugins の重複・過剰、context-budget の整備、MCP 過剰登録なし |
## Misskey 固有の確認項目 (採点根拠コマンド)
@@ -44,18 +46,9 @@ Misskey リポジトリの `.claude/` 構成を 7 カテゴリで採点し、改
採点時に以下を実コマンドで確認する。各項目の **属するカテゴリ** は項目内に明記する (#1-#3 は Security Guardrails、#4 は Tool Coverage、#5 は Quality Gates):
```bash
# 1. [Security Guardrails] SPDX 適用率 (新規ファイル想定の汎用チェック)
# - node_modules を prune で除外
# - packages/misskey-js は MIT サブパッケージなので AGPL ヘッダーを持たない (AGENTS.md §1) → 除外
# - built/ なども除外
# 候補にはなお *.config.{ts,js} / *eslint* / *.d.ts のような CI 上 SPDX 対象外
# (.github/workflows/check-spdx-license-id.yml の exclude 参照) も混ざるため、
# 上位に出たファイルが「新規追加した実コード」かどうかは目視判定する。
find packages \
\( -type d \( -name node_modules -o -name built -o -name dist -o -path 'packages/misskey-js' \) -prune \) \
-o -type f \( -name '*.ts' -o -name '*.js' -o -name '*.vue' -o -name '*.scss' \) -print \
| xargs -r grep -L 'SPDX-License-Identifier: AGPL-3.0-only' | head -20
# → 上位に新規実コードが無ければ満点
# 1. [Security Guardrails] SPDX 対象・CI 判定・HTML コメント形式
node scripts/check-spdx.mjs
# → exit 0 (`SPDX: OK`) なら満点。追加の目視判定はしない
# 2. [Security Guardrails] ja-JP.yml 以外の locales が直近で手動編集されていないか
# --pretty=format: でコミットヘッダ行を抑止し、ファイル名行のみを残してから grep する。
@@ -105,20 +98,19 @@ grep -rn 'console\.\(log\|debug\)' packages/backend/src packages/frontend/src 2>
## サンプル出力
```text
Harness Audit (repo): 55/70
Harness Audit (repo): 52/70
Tool Coverage: 9/10 (skills 5, agents 2, commands 5 — 偏りなし)
Context Efficiency: 8/10 (description 平均 3-5 行、肥大なし)
Quality Gates: 5/10 (Stop hook 共有設定に未登録 / `/quality-gate` あり)
Quality Gates: 7/10 (変更ファイル lint / `/quality-gate` あり、広域 gate は任意)
Memory Persistence: 5/10 (プロジェクト側 memory/ 未採用方針 = 既定値)
Eval Coverage: 7/10 (backend/frontend testing リファレンス網羅、Storybook 一部抜け)
Security Guardrails: 10/10 (SPDX 100%, locales OK, migrations clean)
Security Guardrails: 8/10 (SPDX 欠落 1 件、locales OKmigrations clean)
Cost Efficiency: 8/10 (context-budget 導入済 / MCP 0)
Failed Checks:
- packages/frontend/src/.../X.vue で SPDX 欠落 (Security Guardrails)
- console.log が backend に 3 件 (Quality Gates)
- 共有 Stop hook なし (Quality Gates) — 各 contributor が `.claude/settings.local.json` で opt-in する方針なら減点しなくて良い
Top 3 Actions:
1) [Security Guardrails] SPDX 欠落 1 ファイルを修正:
@@ -137,10 +129,10 @@ Suggested next skills to apply:
- 確定的: 同じ commit / 同じ `.claude/` 構成なら同じスコア
- ヒューリスティクス: 「description の冗長度」のような主観項目は同一基準で機械的に判定
- スクリプト不要: `pnpm` `git``grep`/`find` 等の標準ツールのみ
- 専用 audit script 不要: repository の `pnpm` / `git` コマンドと SPDX checker を使う
## 参考: ECC オリジナルとの差分
- ECC 版は `node scripts/harness-audit.js` を直叩きする運用で、ECC リポジトリ全体に閉じた採点だった。
- Misskey 版は **Misskey の規約 (SPDX/migration/locales/endpoint-list)** を Security 採点に組み込み、`pnpm` ベースの実コマンドで根拠を取る方式に再設計。
- Misskey 版は **Misskey の規約 (SPDX/migration/locales/endpoint-list)** を Security 採点に組み込み、repository-native command で根拠を取る方式に再設計。
- 結果として ECC への依存はゼロ。
+32 -37
View File
@@ -1,5 +1,5 @@
---
description: Misskey の lint / typecheck / 高速テストを順に実行して品質ゲートを通すコマンド。完了前の軽量検証
description: Misskey の lint / typecheck / 高速テストを順に実行する任意の広域品質検証。
argument-hint: "[repo|backend|frontend|<path/to/file.ts>]"
---
@@ -14,33 +14,35 @@ project-level notice: see .claude/THIRD_PARTY_LICENSES.md (Misskey 内サード
Imported into Misskey .claude/ on 2026-05-10. Pipeline 概念 (lint → typecheck → test) は upstream ECC 版から借用 (MIT)。実コマンド層は Misskey の pnpm + tsc + ESLint + Vitest に固定し、formatter (Prettier/Biome) フェーズは削除した。
note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック版だったが、Misskey 専用に pnpm + tsc + ESLint + Vitest の組み合わせに固定。重い test:e2e / test:fed は含まない (CI 側で実行される)。
note: 元 ECC 版は言語自動判定 + format/lint/type のジェネリック版だったが、Misskey 専用に pnpm + tsc + ESLint + Vitest の組み合わせに固定。
重い test:e2e / test:fed は含めず、変更内容または明示依頼に応じて個別実行する。
-->
# /quality-gate — Misskey 軽量品質ゲート
# /quality-gate — Misskey 広域品質検証
`/quality-gate [scope]`
完了前の **軽量** 品質チェック。重い E2E / 連合テスト (test:e2e / test:fed / Playwright) は CI 側で実行されるため、本コマンドには含めない
package または repo 全体の状態が必要なときに任意で使う
完了時に必須の変更ファイル lint は [shipping-misskey-change](../skills/shipping-misskey-change/SKILL.md) が担当する。
## Scope
- `repo` (default) — 全パッケージ
- `repo` (default) — 全 workspace の lint + backend / frontend の unit test
- `backend``packages/backend` のみ
- `frontend``packages/frontend` のみ
- `path/to/file.ts` — 単一ファイルへの ESLint --fix のみ
- `path/to/file.ts` — 単一ファイルへの ESLint `--quiet` のみ
## Pipeline
### Repo scope (全部)
### Repo scope
各パッケージの `lint` スクリプト実体は `pnpm typecheck && pnpm eslint` ([packages/backend/package.json](../../packages/backend/package.json), [packages/frontend/package.json](../../packages/frontend/package.json)) で、ルートの `pnpm lint``pnpm --no-bail -r lint` (= 全パッケージで lint を `--no-bail` で実行)。**typecheck は lint に含まれている**ため、通常はこの 2 コマンドで十分:
各パッケージの `lint` スクリプト実体は `pnpm typecheck && pnpm eslint` ([packages/backend/package.json](../../packages/backend/package.json), [packages/frontend/package.json](../../packages/frontend/package.json))
ルートの `pnpm lint``pnpm --no-bail -r lint && pnpm check-dts` なので、そのまま実行すると workspace lint の失敗時に `check-dts` が実行されない。
次の 4 コマンドをそれぞれ独立した Bash 呼び出しとして実行し、先の失敗にかかわらず全結果を収集する:
```bash
# 1. Lint (= typecheck + ESLint、全パッケージ。--no-bail で最初の失敗で止まらず全結果を集める)
pnpm lint
# 2. Unit test (高速、e2e は含まない)
pnpm --no-bail -r lint
pnpm check-dts
pnpm --filter backend test
pnpm --filter frontend test
```
@@ -56,7 +58,8 @@ pnpm --filter frontend typecheck # vue-tsc 単体 (Vue SFC の型を見るた
### Backend scope
`pnpm --filter backend lint` は内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/backend/package.json](../../packages/backend/package.json)) ので、`lint` を回せば typecheck も終わる。軽量ゲートでは typecheck の二重実行を避けるため `lint` + `test` のみ:
`pnpm --filter backend lint` は内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/backend/package.json](../../packages/backend/package.json)) ので、`lint` を回せば typecheck も終わる。
広域検証では typecheck の二重実行を避けるため `lint` + `test` のみ:
```bash
pnpm --filter backend lint
@@ -67,7 +70,7 @@ pnpm --filter backend test
### Frontend scope
`pnpm --filter frontend lint` も内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/frontend/package.json](../../packages/frontend/package.json)) ため、軽量ゲートでは Backend 同様に `lint` + `test` のみ:
`pnpm --filter frontend lint` も内部で `pnpm typecheck && pnpm eslint` を実行する ([packages/frontend/package.json](../../packages/frontend/package.json)) ため、広域検証では Backend 同様に `lint` + `test` のみ:
```bash
pnpm --filter frontend lint
@@ -78,46 +81,38 @@ pnpm --filter frontend test
### Single file scope
repo-relative path を package-relative path に変換し、該当 package root で実行する。
```bash
pnpm exec eslint --fix <path>
(cd packages/backend && pnpm exec eslint --quiet -- src/path/to/file.ts)
(cd packages/frontend && pnpm exec eslint --quiet -- src/path/to/component.vue)
```
## Output
実行したフェーズの pass/fail と件数を集計する。標準パイプラインは `pnpm lint` (typecheck 内包) と unit test のみなので、デフォルトの出力は以下のようになる:
各コマンドの終了コードを保持し、実行項目を `PASS / FAIL / BASELINE / SKIPPED` で集計する。
`BASELINE` は同じ失敗が base 側でも再現し、今回の変更と無関係と確認できた場合だけ使う。
```text
Quality Gate (repo):
Lint: PASS (0 errors, 2 warnings)
Backend ut: PASS (412/412)
Frontend ut: PASS (87/87)
→ 完了前の軽量チェック OK。重い e2e / 連合テストは CI 側で実行される。
Lint: PASS
Backend ut: BASELINE (base 側でも同じ既存失敗)
Frontend ut: PASS
Other tests: SKIPPED (repo scope の対象外)
```
`#### 詳細を分けて見たい時のみ (optional)` で個別 typecheck (`pnpm --filter backend typecheck` / `pnpm --filter frontend typecheck`) も回した場合のみ、その結果を追加行として表示する:
```text
Quality Gate (repo):
Lint: PASS (0 errors, 2 warnings)
Backend tc: PASS (0 errors) # optional 実行時のみ
Frontend tc: PASS (0 errors) # optional 実行時のみ
Backend ut: PASS (412/412)
Frontend ut: PASS (87/87)
```
失敗時は最初に落ちたフェーズで停止して詳細を見せる。
一つが失敗しても独立した残りの検証は続ける。
`BASELINE``PASS` と表示せず、未実行の項目は理由とともに `SKIPPED` とする。
## 関連 skill / コマンド
- [`shipping-misskey-change` スキル](../skills/shipping-misskey-change/SKILL.md) — commit / PR 直前の最終チェックリスト (misskey-js 再生成 / SPDX / CHANGELOG 等)
- [`shipping-misskey-change` スキル](../skills/shipping-misskey-change/SKILL.md) — commit / PR 直前の最終チェックリスト
- [`shipping-misskey-change/references/tasks/regenerate-misskey-js.md`](../skills/shipping-misskey-change/references/tasks/regenerate-misskey-js.md) — API 変更時の `pnpm build-misskey-js-with-types` 実行手順
- [.github/copilot-instructions.md §Validation コマンド](../../.github/copilot-instructions.md) — pnpm コマンド一覧 (Copilot / Codex 向けに再掲)
## 元 ECC 版との差分
- ジェネリックな言語自動判定を排除し、Misskey 固定 pipeline に。
- formatter フェーズなし (Misskey は ESLint --fix のみ採用)。
- e2e / federation / Playwright は重いため除外し CI 側に委譲
- formatter フェーズなし (変更ファイル lint は ESLint `--quiet`)。
- e2e / federation / Playwright は scope に自動追加せず、変更内容または明示依頼に応じて個別実行
+39 -19
View File
@@ -1,33 +1,53 @@
---
name: shipping-misskey-change
description: Use at every "finish" moment of a Misskey change — immediately before committing, opening a PR, merging, or handing the work back to the user even without a commit. Runs the final pre-ship checklist — `pnpm lint`, misskey-js regeneration (`pnpm build-misskey-js-with-types`) when backend API changed, `pnpm --filter backend check-migrations` when entities or migrations changed, SPDX header verification on new files, locale safety check (no edits to non-`ja-JP` locale yml files), and `CHANGELOG.md` Unreleased entry for user-visible changes. Must be consulted as the last step of every change — including uncommitted handoffs — to avoid CI failures and lost translations. This is NOT waived by having already invoked brainstorming, writing-plans, or any other upstream skill — invoke this regardless of what preceded it.
description: Use at every finish moment of a Misskey change, before committing, opening a PR, merging, or handing work back. Selects proportional validation, runs changed-file lint and repository safety checks, and records PASS/FAIL/BASELINE/SKIPPED without chasing unrelated failures.
---
# shipping-misskey-change
Misskey の変更**finish 局面** (commit / PR / merge する直前、またはコミットせずユーザーに作業を返す直前) に必ず走らせる最終チェックリスト
Misskey の変更commit / PR / merge する直前、または未commitでユーザーへ返す直前の出口
規範は [AGENTS.md](../../../AGENTS.md)、ここでは実行方法だけを定める。
CI で落ちやすい / レビュアーから指摘されやすいポイントを 1 箇所に集めている。後で references を辿る余裕を作らないため、チェックリストは SKILL.md 本体に直書きする。
## 1. 検証レベル
**他スキル実行後も免除されない。** `brainstorming` / `writing-plans` / その他アップストリームスキルを先に呼んでいても、作業を返す直前・commit 直前のタイミングでこのスキルを呼ぶこと。
| 段 | 条件 | 実行 |
| --- | --- | --- |
| 1 (必須) | package の ESLint 対象ファイルを変更 | 存在する変更ファイルへ `eslint --quiet` を最後に 1 回 |
| 2 | 実装・挙動を変更 | 最も近い unit test を実行。型・生成物・DB に関係するときは対応する専用検証も実行 |
| 3 (任意) | 明示依頼、広域変更、切り分けに必要 | package / repo 全体の lint、build、広域 test |
## 最終チェックリスト
段 1 は docs-only など対象が空なら `SKIPPED`
段 3 の既存失敗は成功扱いせず `BASELINE` として、今回の変更との関係だけを報告する。
このリストを TodoWrite に展開して 1 項目ずつ確認すること。**該当しない項目は飛ばして良いが、判断は明示する**。
### 自動検査
- [ ] lint が通る — ECC 由来の [/quality-gate](../../commands/quality-gate.md) コマンドで lint (typecheck + eslint) + 高速テストをまとめて回すのが基本。lint だけ単発で確認したいなら `pnpm lint` 直接でもよい
- [ ] backend で `meta` / `paramDef` / `res` を変更した → `pnpm build-misskey-js-with-types` を実行して `packages/misskey-js/src/autogen/` の差分も commit に含めた → 詳細手順は [references/tasks/regenerate-misskey-js.md](references/tasks/regenerate-misskey-js.md)
- [ ] エンティティ (`packages/backend/src/models/*.ts``@Column` / `@Entity` / `@Index`) を変更した → `pnpm --filter backend check-migrations` が pending DDL 0 件で通る
- [ ] migration ファイルを追加した → `up()``down()` の両方を実装した / 既存のマージ済 migration は一切触っていない
- [ ] 新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.vue` / `.scss` / `.html` ファイルを追加した → SPDX ヘッダーを付けた (`.vue` / `.html` は HTML コメント形式、その他は TS コメント形式)
- [ ] `locales/` を編集した → **`ja-JP.yml` だけ** を変更しており、他言語 yml の diff は出ていない (`git diff --name-only develop -- 'locales/*.yml' | grep -v '^locales/ja-JP\.yml$'` が空)
- [ ] ユーザーから見える変更 (機能追加 / 既存挙動変更) → `CHANGELOG.md``## Unreleased` 直下の該当サブセクション (General / Client / Server) に 1 行追記した → 詳細書式は [references/tasks/changelog-update.md](references/tasks/changelog-update.md)
- [ ] backend API endpoint を追加・変更した → [misskey-api-reviewer](../../agents/misskey-api-reviewer.md) agent を Task で起動して機械レビューする (endpoint-list 登録漏れ / misskey-js 再生成漏れ / meta・UUID / SPDX。lint や CI では拾いにくい 404・登録漏れの最終関門なので、該当する変更があれば飛ばさない)
- [ ] frontend の `.vue` を追加・変更した → [vue-component-reviewer](../../agents/vue-component-reviewer.md) agent を Task で起動して機械レビューする (SPDX 形式 / 命名 / i18n / SCSS 変数 / os.* / a11y / Storybook 併設)
- [ ] (任意) `.claude/` ハーネス自体の健全性を確認したい → ECC 由来の [/harness-audit](../../commands/harness-audit.md) コマンドを実行
repo root で次を 1 回実行する。
## 何のためのスキルか
```bash
node scripts/check-shipping.mjs
```
これは「**作業中に何を作るか**」を決めるスキルではなく、「**作り終わった後に CI を通す**」スキル。`working-on-backend` / `working-on-frontend` から始まった作業の **出口** として機能する
統合先を明示する場合は `--base <ref>`、または `MISSKEY_BASE_REF` を使う
script は次を行い、独立した検査を最後まで続けて exit 0 (合格) / 1 (違反) / 2 (検査不能) に集約する。
該当する変更がある場合は各 references/tasks/ を Read して詳細手順を踏むこと。`pnpm lint` だけは references を読まずに直接走らせて良い (`/quality-gate` でまとめて回せる)。
- commit 済み・未commit・untracked の変更集合を NUL-safe に列挙し、変更ファイルだけへ package root から `eslint --quiet` を実行
- SPDX 違反時はローカル変更の欠落だけ `check-spdx.mjs --fix` で補い、通常検査を再実行
- SPDX の結果にかかわらず、`locales/ja-JP.yml` 以外の locale YAML 変更を検査
`SPDX: OK` 後は追加確認しない。
その他の常設方針は AGENTS.md に従う。
## 2. 変更別チェック
- backend API の `meta` / `paramDef` / `res`: `pnpm build-misskey-js-with-types`
手順は [regenerate-misskey-js.md](references/tasks/regenerate-misskey-js.md)
- entity / migration: `pnpm --filter backend check-migrations`
新規 migration は `up()` / `down()`、既存のマージ済 migration は差分なし
- backend API endpoint: [misskey-api-reviewer](../../agents/misskey-api-reviewer.md) を実行
- frontend `.vue`: [vue-component-reviewer](../../agents/vue-component-reviewer.md) を実行
## 3. 引き継ぎ
実行項目を `PASS / FAIL / BASELINE / SKIPPED` で短く列挙する。
失敗時は今回の変更との関係、未実行時は理由を書く。
ユーザーが依頼していない commit / PR / 外部送信は行わない。
@@ -1,6 +1,7 @@
# CHANGELOG.md の Unreleased セクションに 1 行追記する
# 明示依頼時に CHANGELOG.md の Unreleased セクションを更新する
ユーザー影響のある変更 (機能追加・修正・改善) は `CHANGELOG.md` の冒頭 `## Unreleased` セクションに 1 行追加する。リファクタリング等の内部変更は不要
ユーザーから CHANGELOG 編集を明示的に依頼された場合だけ読む書式リファレンス
通常の実装・修正では編集しない。
## セクション構造
+1 -1
View File
@@ -30,6 +30,6 @@ SKILL.md 本体は references への索引だけ。具体的な手順や規約
## 必ず最後に通る場所
backend の変更を commit / PR にする前に、必ず [shipping-misskey-change](../shipping-misskey-change/SKILL.md) の最終チェックリストに従う。`pnpm lint` / misskey-js 再生成 / `check-migrations` / SPDX / CHANGELOG をまとめて確認する。
backend の変更を commit / PR にする前に、必ず [shipping-misskey-change](../shipping-misskey-change/SKILL.md) の最終チェックリストに従う。
API endpoint を追加・変更したなら、その出口で [misskey-api-reviewer](../../agents/misskey-api-reviewer.md) agent (この skill の規約を review-mode から機械チェックする専門 reviewer) を Task で起動すると、endpoint-list 登録漏れや misskey-js 再生成漏れを取りこぼしにくい。
@@ -15,7 +15,6 @@
2. 実装 : meta / paramDef / クラス本体を書く (SPDX ヘッダー付き)
3. 配線 : endpoint-list.ts に登録 (★ 忘れると 404)
4. 検証 : e2e テスト + lint + misskey-js 再生成
5. 仕上げ : CHANGELOG エントリ (shipping-misskey-change で確認)
```
---
@@ -254,12 +253,6 @@ PR に `packages/misskey-js/src/autogen/` 配下の差分が含まれていな
---
## 5. 仕上げフェーズ — CHANGELOG
ユーザー影響がある (新機能 / 既存挙動変更) なら `CHANGELOG.md``## Unreleased``### Server` に 1 行追加する。詳細は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) に従う。
---
## 落とし穴サマリ (PR で頻発するミス)
詳細な症状 → 原因 → 修正 のフォーマット → **[knowledge/api-meta-paramdef.md](../knowledge/api-meta-paramdef.md) §落とし穴**
@@ -161,12 +161,6 @@ pnpm migrate
---
## CHANGELOG (ユーザー影響がある場合)
スキーマ変更がユーザーに見える挙動を生む場合のみ、`CHANGELOG.md` に追記する。内部リファクタや純粋なインデックス追加は不要。詳細は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) で確認。
---
## 提出前セルフレビューチェックリスト
完了前に以下を上から確認する (各項目を TodoWrite 化してよい):
@@ -177,4 +171,3 @@ pnpm migrate
- [ ] `up()` の各文に対応する巻き戻しが `down()` にあり、**`down()` が空でない** (難ケースは [knowledge/typeorm-patterns.md](../knowledge/typeorm-patterns.md) を確認済み)
- [ ] `pnpm --filter backend check-migrations` が **0 件 (pending DDL なし)** で通る
- [ ] (可能なら) `pnpm migrate` → `pnpm revert` → `pnpm migrate` が通る
- [ ] ユーザーに見える変更なら CHANGELOG 追記 → [shipping-misskey-change](../../../shipping-misskey-change/SKILL.md)
+3 -3
View File
@@ -1,6 +1,6 @@
---
name: working-on-frontend
description: Use whenever editing or adding code under `packages/frontend/`, or editing `locales/ja-JP.yml` for frontend-facing UI text — including Vue 3 SFCs (`Mk*` components), i18n keys (`i18n.ts.<key>` / `i18n.tsx.<key>()`), SCSS Modules, theme/CSS variables, `os.*` UI helpers, and Storybook stories. Covers SPDX (HTML comment form), `<script setup lang="ts">` conventions, type-only defineProps, `ja-JP.yml`-only locale editing (other locale yml files are Crowdin-managed and must not be edited), and accessibility. Must be consulted before any frontend or UI-locale change to avoid CI failures, lost translations, and reviewer pushback. This is NOT waived by having already invoked brainstorming, writing-plans, or any other upstream skill — invoke this at implementation time regardless of what preceded it.
description: Use whenever editing or adding code under `packages/frontend/`, or editing `locales/ja-JP.yml` for frontend-facing UI text — including Vue 3 SFCs (`Mk*` components), i18n keys (`i18n.ts.<key>` / `i18n.tsx.<key>()`), SCSS Modules, theme/CSS variables, `os.*` UI helpers, and Storybook stories. Covers `<script setup lang="ts">` conventions, type-only defineProps, `ja-JP.yml`-only locale editing (other locale yml files are Crowdin-managed and must not be edited), and accessibility. Must be consulted before any frontend or UI-locale change to avoid CI failures, lost translations, and reviewer pushback. This is NOT waived by having already invoked brainstorming, writing-plans, or any other upstream skill — invoke this at implementation time regardless of what preceded it.
---
# working-on-frontend
@@ -31,6 +31,6 @@ SKILL.md 本体は references への索引だけ。具体的な手順や規約
## 必ず最後に通る場所
frontend の変更を commit / PR にする前に、必ず [shipping-misskey-change](../shipping-misskey-change/SKILL.md) の最終チェックリストに従う。`pnpm lint` / SPDX / `ja-JP.yml` のみ編集確認 / CHANGELOG をまとめて確認する。
frontend の変更を commit / PR にする前に、必ず [shipping-misskey-change](../shipping-misskey-change/SKILL.md) の最終チェックリストに従う。
`.vue` を追加・変更したなら、その出口で [vue-component-reviewer](../../agents/vue-component-reviewer.md) agent (この skill の規約を review-mode から機械チェックする専門 reviewer) を Task で起動すると、SPDX 形式・命名・i18n・SCSS 変数・a11y・Storybook 併設の逸脱を取りこぼしにくい。
`.vue` を追加・変更したなら、その出口で [vue-component-reviewer](../../agents/vue-component-reviewer.md) agent (この skill の規約を review-mode から機械チェックする専門 reviewer) を Task で起動すると、命名・i18n・SCSS 変数・a11y・Storybook 併設の逸脱を取りこぼしにくい。
@@ -227,18 +227,6 @@ git checkout HEAD -- locales/zh-CN.yml
PR 化前なら何度でもやり直せる。**マージしてしまうと Crowdin 側との整合性が崩れて手動回復が必要** になるので、PR レビュー段階で必ず `locales/*.yml` (ja-JP 以外) の diff がゼロであることを確認する。
### CHANGELOG 記載の判定
| 変更内容 | CHANGELOG 記載 |
|---|---|
| 新規画面追加と一緒に新キー追加 | 必要 (`### Client` に Feat/Enhance) |
| 既存文言の改善 (誤字脱字以外) | 必要 (`### Client` に Enhance) |
| 誤字脱字・微妙な言い回し修正 | 不要 |
| キーのリネーム (UI 変化なし) | 不要 |
| キー削除 (画面から消える) | 必要 (`### Client` に Feat / 機能削除) |
書き方は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) を参照。
## トラブルシュート
i18n 周辺で踏みやすい失敗とその対処。エラー文字列で grep してたどり着けるよう整理。
@@ -88,8 +88,6 @@ git diff --name-only develop -- 'locales/*.yml' | grep -v '^locales/ja-JP\.yml$'
**注意:** `grep -v 'ja-JP.yml'`**diff 本文** に当てると ja-JP.yml 単体の変更でも `+追加行` が素通りして必ず非空になる。`--name-only` でファイル名だけに絞ってから完全一致で除外するのが正しい。
ユーザー影響のある UI 変更を伴う場合は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) で CHANGELOG エントリの判定をする。
## 例: 「ノートを削除しますか?」確認ダイアログを追加する
1. `locales/ja-JP.yml`:
@@ -174,10 +174,6 @@ pnpm --filter frontend storybook-dev # localhost:6006
pnpm --filter frontend test
```
## CHANGELOG エントリ
ユーザーから見える変更 (新規コンポーネントが新しい UI として露出する、既存 UI の挙動を変える) なら、`CHANGELOG.md` に追記する。判定方法と書式は [shipping-misskey-change スキル](../../../shipping-misskey-change/SKILL.md) で確認。
## 既存コンポーネントとの整合性
- 似た用途の既存 `Mk*` を 1-2 個読んで、props 命名 (`primary` / `danger` / `small` 等の形容詞、`onClose` ではなく `emit('close')` 等) を揃える
-2
View File
@@ -1,2 +0,0 @@
web_search = "live"
sandbox_mode = "workspace-write"
+11 -6
View File
@@ -10,7 +10,8 @@
### コード・データ関連
- **SPDX ヘッダー必須**: AGPL-3.0-only 管轄かつ SPDX CI 対象ディレクトリに新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.scss` / `.vue` / `.html` ファイルを追加する場合は冒頭に必ず付ける。詳細な対象判定は `.github/workflows/check-spdx-license-id.yml` を参照。
- **SPDX ヘッダー必須**: AGPL-3.0-only 管轄かつ SPDX CI 対象ディレクトリに新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.scss` / `.vue` / `.html` ファイルを追加する場合は冒頭に必ず付ける。
対象判定は `scripts/check-spdx.mjs` を参照。
```text
/*
@@ -29,6 +30,8 @@
```
`packages/misskey-js` は MIT ライセンスのサブパッケージなので、この AGPL ヘッダーを一律に付けない (サブパッケージ固有の `package.json` / `LICENSE` / 既存ファイルのヘッダーに従う)。
SPDX の合否は CI と skill が同じ `scripts/check-spdx.mjs` で判定するため、code review で目視チェックを重ねない。
CI は `--ci` で SPDX 行の有無を検査し、既定モードは加えて `.vue` / `.html` のコメント形式を検査する。
- **`locales/ja-JP.yml` 以外の locale YAML を編集しない**。他言語ファイル (`en-US.yml` など `ja-JP.yml` 以外すべて) は Crowdin の自動配信先で、手動編集すると次の同期で上書き喪失する。
- **マージ済 migration を編集しない**。`packages/backend/migration/{timestamp}-*.js` のうち既に `develop` / `master` に入ったものは絶対に変更しない。スキーマ変更が必要なら新しい timestamp で新規ファイルを追加し、`up()` と `down()` の両方を実装する。
@@ -49,17 +52,19 @@
## 変更を出す前の最低チェック
1. `pnpm lint` が通る (typecheck + eslint, 全パッケージ)
1. ESLint 対象の変更ファイルへ package root から `eslint --quiet` を最後に 1 回実行し、実装変更には最も近い test を選んで実行する。
package / repo 全体 lint と広域 test は任意
2. backend で `meta` / `paramDef` / `res` を変更した → `pnpm build-misskey-js-with-types` を実行し `packages/misskey-js/src/autogen/` の差分も commit に含めた
3. entity / migration を変更した → `pnpm --filter backend check-migrations` が pending DDL 0 件で通る / 新規 migration は `up()` と `down()` 両方実装済
4. 新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.vue` / `.scss` / `.html` ファイルを追加した → SPDX ヘッダーを付け
5. ユーザー影響のある変更 → `CHANGELOG.md` の `## Unreleased` 配下の該当サブセクション (`### General` / `### Client` / `### Server`) に `- <Feat|Enhance|Fix>: <概要>` を 1 行追記
6. `locales/` を編集した場合、`git diff --name-only develop -- 'locales/*.yml' | grep -v '^locales/ja-JP\.yml$'` が空 (ja-JP.yml 以外に差分が無い) ことを確認
4. `node scripts/check-spdx.mjs` `SPDX: OK` を返し
5. ユーザーが明示しない限り `CHANGELOG.md` を編集しない。
ユーザー影響がある変更では引き継ぎに候補を 1 行だけ示す
6. commit 済み・未commit・untracked の変更集合に `locales/ja-JP.yml` 以外の locale YAML が無いことを確認する
## Validation コマンド
- 全体ビルド: `pnpm build`
- 全体 lint / typecheck: `pnpm lint`
- 全体 lint / typecheck (任意): `pnpm lint`
- Backend unit test: `pnpm --filter backend test`
- Backend e2e test: `pnpm --filter backend test:e2e`
- Backend federation test: `pnpm --filter backend test:fed`
+1 -64
View File
@@ -14,67 +14,4 @@ jobs:
- name: Checkout
uses: actions/checkout@v7.0.0
- name: Check
run: |
counter=0
search() {
local directory="$1"
find "$directory" -type f \
'(' \
-name "*.cjs" -and -not -name '*.config.cjs' -o \
-name "*.html" -o \
-name "*.js" -and -not -name '*.config.js' -o \
-name "*.mjs" -and -not -name '*.config.mjs' -o \
-name "*.scss" -o \
-name "*.ts" -and -not -name '*.config.ts' -o \
-name "*.vue" \
')' -and \
-not -name '*eslint*'
}
check() {
local file="$1"
if ! (
grep -q "SPDX-FileCopyrightText: syuilo and misskey-project" "$file" ||
grep -q "SPDX-License-Identifier: AGPL-3.0-only" "$file"
); then
echo "Missing: $file"
((counter++))
fi
}
directories=(
"packages/backend/migration"
"packages/backend/src"
"packages/backend/test"
"packages/frontend-shared/@types"
"packages/frontend-shared/js"
"packages/frontend-builder"
"packages/frontend/.storybook"
"packages/frontend/@types"
"packages/frontend/lib"
"packages/frontend/public"
"packages/frontend/src"
"packages/frontend/test"
"packages/frontend-embed/@types"
"packages/frontend-embed/src"
"packages/icons-subsetter/src"
"packages/misskey-bubble-game/src"
"packages/misskey-reversi/src"
"packages/sw/src"
"scripts"
)
for directory in "${directories[@]}"; do
for file in $(search $directory); do
check "$file"
done
done
if [ $counter -gt 0 ]; then
echo "SPDX-License-Identifier is missing in $counter files."
exit 1
else
echo "SPDX-License-Identifier is certainly described in all target files!"
exit 0
fi
run: node scripts/check-spdx.mjs --ci
-6
View File
@@ -16,8 +16,6 @@ on:
- packages/misskey-js/**
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/misskey-world/**
- packages/frontend-misskey-world-engine/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
@@ -34,8 +32,6 @@ on:
- packages/misskey-js/**
- packages/misskey-bubble-game/**
- packages/misskey-reversi/**
- packages/misskey-world/**
- packages/frontend-misskey-world-engine/**
- packages/shared/eslint.config.js
- scripts/check-dts*.mjs
- .github/workflows/lint.yml
@@ -73,8 +69,6 @@ jobs:
- misskey-js
- misskey-bubble-game
- misskey-reversi
- misskey-world
- frontend-misskey-world-engine
env:
eslint-cache-version: v1
eslint-cache-path: ${{ github.workspace }}/node_modules/.cache/eslint-${{ matrix.workspace }}
+11 -26
View File
@@ -18,28 +18,11 @@
1. **SPDX ヘッダー欠落のまま AGPL 管轄ディレクトリへ新規ファイルを追加しない**
- 対象: 新規 `.ts` / `.js` / `.cjs` / `.mjs` / `.vue` / `.scss` / `.html` ファイル
- CI の対象判定は [.github/workflows/check-spdx-license-id.yml](.github/workflows/check-spdx-license-id.yml) の `directories` 配列を参照 (`*.config.{ts,js,cjs,mjs}``*eslint*` は除外)
- 欠落すると CI (`spdx` ジョブ) が失敗する
- 対象判定は [scripts/check-spdx.mjs](scripts/check-spdx.mjs) が一元管理する
- `node scripts/check-spdx.mjs` を 1 回実行し、欠落は `--fix` で補う。
`SPDX: OK` なら追加の目視確認はしない
- `packages/misskey-js` は MIT ライセンスのサブパッケージなので、この AGPL ヘッダーを一律に付けない (サブパッケージ固有の `package.json` / `LICENSE` / 既存ファイルのヘッダーに従う)
`.ts` / `.js` / `.cjs` / `.mjs` / `.scss`:
```text
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
```
`.vue` / `.html` (HTML コメント形式):
```text
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
```
2. **`locales/ja-JP.yml` 以外の locale YAML を手動編集しない**
- 他言語ファイル (`en-US.yml` など `ja-JP.yml` 以外すべて) は Crowdin の自動配信先。手動編集すると次の同期で上書き喪失する
- 根拠: [locales/README.md](locales/README.md) と [crowdin.yml](crowdin.yml) (`ja-JP.yml``locales/%locale%.yml` の同期設定)
@@ -53,7 +36,7 @@
### Git / リポジトリ操作
4. **`git push --force` / `--force-with-lease``main` / `develop` / `master` にしない** (他人の作業を消す可能性)
5. **`git commit --no-verify` で hook をスキップしない** (lint / format / SPDX チェックを潰す)
5. **`git commit --no-verify` で hook をスキップしない**
6. **マージ済 / プッシュ済コミットを `git commit --amend` で書き換えない** (履歴の整合性が壊れる)
7. **他人のブランチを `git reset --hard` / `git branch -D` で破壊しない**
8. **`git config` をユーザーに無断で書き換えない** (特に `user.name` / `user.email` / `commit.gpgsign`)
@@ -80,12 +63,14 @@
各エージェントは [shipping-misskey-change スキル](.claude/skills/shipping-misskey-change/SKILL.md) を参照すること。スキルが利用できない環境でも、以下のチェックは必ず実施すること:
1. **lint**: `pnpm lint` が通る (typecheck + eslint, 全パッケージ)
1. **lint / test**: ESLint 対象の変更ファイルへ package root から `eslint --quiet` を最後に 1 回実行し、実装変更には最も近い test を選んで実行する。
package / repo 全体 lint と広域 test は任意
2. **backend API 変更時**: `pnpm build-misskey-js-with-types` を実行し `packages/misskey-js/src/autogen/` の差分も commit に含めた
3. **entity / migration 変更時**: `pnpm --filter backend check-migrations` が pending DDL 0 件で通る / 新規 migration は `up()``down()` 両方実装済
4. **新規ファイル**: SPDX ヘッダーを付けた (`.vue` / `.html` は HTML コメント形式、それ以外は TS コメント形式)
5. **ユーザー影響のある変更**: `CHANGELOG.md` の `## Unreleased` 配下の該当サブセクション (`### General` / `### Client` / `### Server`) に `- <Feat|Enhance|Fix>: <概要>` を 1 行追記
6. **locale safety**: `locales/` を編集した場合、`git diff --name-only develop -- 'locales/*.yml' | grep -v '^locales/ja-JP\.yml$'` が空 (ja-JP.yml 以外に差分が無い) ことを確認
4. **SPDX**: `node scripts/check-spdx.mjs``SPDX: OK` を返すことを確認する
5. **locale safety**: commit 済み・未commit・untracked の変更集合に `locales/ja-JP.yml` 以外の locale YAML が無いことを確認する
6. **[CHANGELOG](.claude/skills/shipping-misskey-change/references/tasks/changelog-update.md)**: ユーザーが明示しない限り編集しない。
ユーザー影響がある変更では引き継ぎに候補を 1 行だけ示す
### Validation commands
@@ -93,7 +78,7 @@
| 用途 | コマンド |
| --- | --- |
| 全体 lint (typecheck + eslint) | `pnpm lint` |
| 全体 lint (任意) | `pnpm lint` |
| Backend unit test | `pnpm --filter backend test` |
| Backend e2e test | `pnpm --filter backend test:e2e` |
| Backend federation test | `pnpm --filter backend test:fed` |
+1 -1
View File
@@ -1,4 +1,4 @@
## Unreleased
## 2026.8.0
### General
-
-10
View File
@@ -30,8 +30,6 @@ COPY --link ["packages/sw/package.json", "./packages/sw/"]
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
COPY --link ["packages/misskey-world/package.json", "./packages/misskey-world/"]
COPY --link ["packages/frontend-misskey-world-engine/package.json", "./packages/frontend-misskey-world-engine/"]
ARG NODE_ENV=production
@@ -63,8 +61,6 @@ COPY --link ["packages/backend/package.json", "./packages/backend/"]
COPY --link ["packages/misskey-js/package.json", "./packages/misskey-js/"]
COPY --link ["packages/misskey-reversi/package.json", "./packages/misskey-reversi/"]
COPY --link ["packages/misskey-bubble-game/package.json", "./packages/misskey-bubble-game/"]
COPY --link ["packages/misskey-world/package.json", "./packages/misskey-world/"]
COPY --link ["packages/frontend-misskey-world-engine/package.json", "./packages/frontend-misskey-world-engine/"]
ARG NODE_ENV=production
@@ -103,18 +99,12 @@ COPY --chown=misskey:misskey --from=target-builder /misskey/packages/backend/nod
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-js/node_modules ./packages/misskey-js/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-reversi/node_modules ./packages/misskey-reversi/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-bubble-game/node_modules ./packages/misskey-bubble-game/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-world/node_modules ./packages/misskey-world/node_modules
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/frontend-misskey-world-engine/node_modules ./packages/frontend-misskey-world-engine/node_modules
COPY --chown=misskey:misskey --from=native-builder /misskey/built ./built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-js/built ./packages/misskey-js/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-reversi/built ./packages/misskey-reversi/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/misskey-bubble-game/built ./packages/misskey-bubble-game/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/backend/built ./packages/backend/built
COPY --chown=misskey:misskey --from=native-builder /misskey/packages/i18n/built ./packages/i18n/built
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/misskey-world/built ./packages/misskey-world/built
COPY --chown=misskey:misskey --from=target-builder /misskey/packages/frontend-misskey-world-engine/built ./packages/frontend-misskey-world-engine/built
COPY --chown=misskey:misskey . ./
ENV LD_PRELOAD=/usr/local/lib/libjemalloc.so
@@ -1,51 +0,0 @@
# World Cel-Shading Outline Snapshot Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Keep the scene-wide inverted-hull outline synchronized during WebGPU FAST Snapshot Rendering and prevent rendering-group fallback hooks from growing into a recursive callback chain.
**Architecture:** Give the cel-shading pass its own stable Babylon render-pass ID so its `SubMesh` draw wrappers can be found again during snapshot replay. Before the main draw phase, update each recorded outline wrapper's WebGPU `LeftOver` uniform buffer with current camera, mesh, width, and color values. Track transparent-fallback hooks by `RenderingGroup` object identity rather than numeric group ID, because separate rendering managers reuse the same IDs.
**Tech Stack:** TypeScript, Babylon.js 9.19, Vitest, Babylon `NullEngine` test scenes.
## Global Constraints
- Preserve the existing Scene-wide post-opaque inverted-hull architecture and per-mesh settings.
- Do not create duplicate outline meshes.
- Keep outlines limited to `WorldEngine`; do not enable them in Room or preview engines.
- Preserve callbacks that already occupy `RenderingGroup.onBeforeTransparentRendering`.
- Run only `frontend-misskey-world-engine` targeted tests and diff checks. Do not run visual validation, package/full type checks, or lint.
---
### Task 1: Reproduce both regressions
**Files:**
- Modify: `packages/frontend-misskey-world-engine/test/CelShadingRenderer.test.ts`
- [ ] Add a test that alternates two real `RenderingManager` instances using rendering group ID `0`, then verifies each `RenderingGroup` retains one stable cel-shading callback and its original callback is invoked once.
- [ ] Add a test that renders one outlined mesh, enables a FAST-snapshot test engine state, changes the camera/mesh transform, triggers the real before-draw observable, and verifies the outline draw wrapper receives current `viewProjection`, `world`, `offset`, and `color` values through its `LeftOver` buffer.
- [ ] Run `pnpm --filter frontend-misskey-world-engine test -- test/CelShadingRenderer.test.ts` and confirm both new tests fail for the intended missing behavior.
### Task 2: Fix hook ownership and snapshot uniform updates
**Files:**
- Modify: `packages/frontend-misskey-world-engine/src/CelShadingRenderer.ts`
- [ ] Key fallback-hook ownership by `BABYLON.RenderingGroup` object identity and restore every owned group callback on dispose.
- [ ] Allocate one cel-shading draw-wrapper render-pass ID, pass it to every `OutlineRenderer.render` call, and release it on dispose.
- [ ] Observe `Scene.onBeforeDrawPhaseObservable`. In FAST Snapshot Rendering, find existing outline draw wrappers for the dedicated ID, bind their WebGPU `LeftOver` data buffer, and update current `viewProjection`, effective `world`, local-width `offset`, and per-mesh `color` before bundle replay.
- [ ] Run the targeted renderer test file and confirm all tests pass.
### Task 3: Verify the scoped change
**Files:**
- Modify: `docs/superpowers/specs/2026-08-02-world-cel-shading-outline-design.md`
- [ ] Document the dedicated snapshot wrapper update and RenderingGroup object-identity ownership.
- [ ] Run `pnpm --filter frontend-misskey-world-engine test`.
- [ ] Run `git diff --check` and inspect `git status --short` plus the scoped diff.
- [ ] Do not run visual validation, type checks, lint, or unrelated package tests.
@@ -1,282 +0,0 @@
# World Cel-Shading Outline Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a reusable scene-wide inverted-hull second pass that draws configurable mesh borders in the World lobby, including avatars, while preserving existing materials, colors, and lighting.
**Architecture:** `CelShadingRenderer` observes Babylon's real mesh/group rendering lifecycle. It queues eligible opaque and alpha-test `SubMesh` draws from the main render pass, then replays them once after opaque depth is complete and before particles/transparent meshes. Babylon's registered `OutlineRenderer` supplies the normal-expansion shader and geometry feature support, while the new renderer owns scheduling, front-face culling, per-mesh settings, state restoration, and lifecycle.
**Tech Stack:** TypeScript, Babylon.js 9.19 (`@babylonjs/core/pure.js` in production and `NullEngine` in tests), Vitest 4.1.10, pnpm workspace.
## Global Constraints
- Apply only to `WorldEngine` (the lobby); do not instantiate the renderer in `RoomEngine` or preview engines.
- Include avatar meshes automatically because defaults enable every eligible lobby-scene mesh.
- Exclude the lobby skybox explicitly.
- Do not create duplicate outline meshes and do not set `mesh.renderOutline = true`.
- Do not alter the original material, lighting, shadows, or base color pass.
- Default outline is black and `cm(1)` wide. Per-mesh `enabled`, `color`, and `width` overrides are public.
- Skip alpha-blended, normal-less, non-triangle, disposed, or disabled meshes.
- Limit automated verification to `frontend-misskey-world-engine` Vitest tests. Per user instruction, do not run visual validation, whole-repository type checks, package type checks, or lint.
- New TypeScript files must carry the AGPL SPDX header.
- Use the system Node 26 executable for pnpm in this workspace if the default pnpm shim selects Node 24.
---
### Task 1: Add a targeted renderer test harness and lock down render ordering
**Files:**
- Modify: `packages/frontend-misskey-world-engine/package.json`
- Modify: `pnpm-lock.yaml`
- Create: `packages/frontend-misskey-world-engine/vitest.config.ts`
- Create: `packages/frontend-misskey-world-engine/test/CelShadingRenderer.test.ts`
- Create: `packages/frontend-misskey-world-engine/src/CelShadingRenderer.ts`
- [ ] Add a package-local `test` script and Vitest dependency:
```json
"test": "vitest run --config vitest.config.ts"
```
```json
"vitest": "4.1.10"
```
- [ ] Configure only this package's tests:
```ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['./test/**/*.test.ts'],
environment: 'node',
},
});
```
- [ ] Write the first test before the production module exists. Dynamically import the desired API so the missing module becomes an intentional assertion failure, then build a real Babylon `NullEngine` scene containing one opaque and one alpha-blended box. The observable event contract is:
```ts
expect(events).toEqual(['opaque', 'outline:opaque', 'transparent']);
```
The injected outline delegate records the outline event, but the real Babylon scene, materials, render groups, and mesh callbacks determine the ordering.
- [ ] Run only this test and confirm RED because `CelShadingRenderer` is absent:
```powershell
pnpm --filter frontend-misskey-world-engine test -- test/CelShadingRenderer.test.ts
```
Expected: one failed assertion explaining that the renderer module has not been implemented.
- [ ] Create `CelShadingRenderer.ts` with the public API and lifecycle skeleton:
```ts
export type CelShadingMeshOptions = {
enabled: boolean;
color: BABYLON.Color3;
width: number;
};
export class CelShadingRenderer implements BABYLON.ISceneComponent {
public readonly name = 'CelShadingRenderer';
public readonly scene: BABYLON.Scene;
public constructor(scene: BABYLON.Scene, options?: CelShadingRendererOptions) {}
public setMeshOptions(mesh: BABYLON.Mesh, options: Partial<CelShadingMeshOptions>): void {}
public clearMeshOptions(mesh: BABYLON.Mesh): void {}
public excludeMesh(mesh: BABYLON.Mesh): void {}
public includeMesh(mesh: BABYLON.Mesh): void {}
public register(): void {}
public rebuild(): void {}
public dispose(): void {}
}
```
- [ ] Register an `_afterRenderingMeshStage` collection step and rendering-group observers. On group start, install a chained `RenderingGroup.onBeforeTransparentRendering` fallback. Flush first from `onBeforeParticlesRenderingObservable`, otherwise from the fallback. Clear the queue at group completion.
- [ ] Filter collection to the active camera's output-target or camera render pass, the current group, non-blended materials, and one entry per `SubMesh`. Babylon 9.19 gives the main camera its own render pass ID, so comparing with `Constants.RENDERPASS_MAIN` would reject the actual screen pass. Deduplication prevents `needDepthPrePass` from producing two outline draws.
- [ ] Replace the dynamic import with a normal static import after the first passing implementation exists.
- [ ] Run the targeted test and confirm GREEN.
- [ ] Commit:
```powershell
git add packages/frontend-misskey-world-engine/package.json packages/frontend-misskey-world-engine/vitest.config.ts packages/frontend-misskey-world-engine/test/CelShadingRenderer.test.ts packages/frontend-misskey-world-engine/src/CelShadingRenderer.ts pnpm-lock.yaml
git commit -m "feat(world): add scene-wide outline pass"
```
### Task 2: Add per-mesh policy, eligibility, and draw deduplication
**Files:**
- Modify: `packages/frontend-misskey-world-engine/test/CelShadingRenderer.test.ts`
- Modify: `packages/frontend-misskey-world-engine/src/CelShadingRenderer.ts`
- [ ] Add RED tests using real boxes for these externally visible behaviors:
```ts
renderer.excludeMesh(excluded);
renderer.setMeshOptions(styled, {
color: new BABYLON.Color3(0.25, 0.5, 0.75),
width: 10,
});
```
Assert that excluded meshes emit no outline draw, included meshes do, and the delegate observes the configured color/width on the rendering mesh.
- [ ] Extend the policy test so `includeMesh` re-enables an excluded mesh and `clearMeshOptions` restores constructor defaults. This protects every public policy mutation rather than only the first setter call.
- [ ] Add a RED test in which `material.needDepthPrePass = true`; assert the `SubMesh` is replayed exactly once even though Babylon renders its depth prepass and color pass.
- [ ] Add RED cases showing that an alpha-test mesh does enter the pass, while an alpha-blended mesh, disabled mesh, mesh without normals, and line/point fill mode do not.
- [ ] Confirm RED for the missing policies with the same targeted test command.
- [ ] Implement a `WeakMap<BABYLON.Mesh, Partial<CelShadingMeshOptions>>`. Resolve stored overrides over immutable constructor defaults at collection time. `setMeshOptions` merges existing partial overrides, `clearMeshOptions` deletes them, and include/exclude modify only `enabled`.
- [ ] Implement `isEligibleSubMesh` around observable behavior:
```ts
return options.enabled
&& options.width > 0
&& Number.isFinite(options.width)
&& !mesh.isDisposed()
&& mesh.isEnabled()
&& mesh.isVerticesDataPresent(BABYLON.VertexBuffer.NormalKind)
&& material != null
&& !material.needAlphaBlendingForMesh(mesh)
&& TRIANGLE_FILL_MODES.has(material.fillMode);
```
- [ ] Keep a `Set<BABYLON.SubMesh>` for the active group and enqueue only on the first occurrence.
- [ ] Run the targeted test and confirm GREEN.
- [ ] Commit:
```powershell
git add packages/frontend-misskey-world-engine/src/CelShadingRenderer.ts packages/frontend-misskey-world-engine/test/CelShadingRenderer.test.ts
git commit -m "feat(world): configure outlines per mesh"
```
### Task 3: Implement the inverted-hull draw and complete state restoration
**Files:**
- Modify: `packages/frontend-misskey-world-engine/test/CelShadingRenderer.test.ts`
- Modify: `packages/frontend-misskey-world-engine/src/CelShadingRenderer.ts`
- [ ] Add a RED test with a non-uniformly scaled box. For requested world width `10` and absolute world scale `(2, 4, 5)`, assert that the delegate sees local `mesh.outlineWidth === 2`.
- [ ] Add a RED test for negative world determinant and material side orientation. The delegate must observe front-face culling with winding reversed exactly when Babylon's effective orientation requires it.
- [ ] Add a RED failure-path test. Make the delegate throw after reading temporary outline values, then assert that the mesh's prior `outlineWidth`/`outlineColor` and every captured engine state are restored.
- [ ] Confirm RED for width conversion, front-face culling, or restoration.
- [ ] Resolve world width conservatively:
```ts
const maxWorldScale = Math.max(Math.abs(scale.x), Math.abs(scale.y), Math.abs(scale.z));
const localWidth = maxWorldScale > BABYLON.Epsilon ? options.width / maxWorldScale : 0;
```
- [ ] Compute effective winding with the same material orientation and negative-determinant correction Babylon applies in `Mesh.render`:
```ts
let orientation = material._getEffectiveOrientation(mesh);
if (mesh._getWorldMatrixDeterminant() < 0) {
orientation = orientation === BABYLON.Material.ClockWiseSideOrientation
? BABYLON.Material.CounterClockWiseSideOrientation
: BABYLON.Material.ClockWiseSideOrientation;
}
const reverseSide = orientation === BABYLON.Material.ClockWiseSideOrientation;
```
- [ ] Before every replay, enforce depth test on, depth writes off, alpha disabled, color writes on, zero z-offset, and front-face culling. Use the scene's normal or reverse-depth comparison as appropriate.
- [ ] Temporarily apply the resolved `outlineWidth` and `outlineColor`, then call the injected/default outline delegate:
```ts
this.outlineRenderer.render(entry.subMesh, entry.batch, false);
```
- [ ] Wrap both engine state and temporary mesh fields in `try/finally`, restoring exact prior depth/cull/front-face/z-offset, alpha, color-write, stencil-buffer, and global cull override values even when drawing throws.
- [ ] Set the Babylon outline delegate `enabled = false`, `zOffset = 0`, and `zOffsetUnits = 0` so its built-in before-mesh pass stays inactive and the new scheduler is the only owner of ordering.
- [ ] Run the targeted test and confirm GREEN.
- [ ] Commit:
```powershell
git add packages/frontend-misskey-world-engine/src/CelShadingRenderer.ts packages/frontend-misskey-world-engine/test/CelShadingRenderer.test.ts
git commit -m "feat(world): render configurable inverted hulls"
```
### Task 4: Wire the reusable renderer into the lobby and exclude the skybox
**Files:**
- Modify: `packages/frontend-misskey-world-engine/src/babylonRuntime.ts`
- Modify: `packages/frontend-misskey-world-engine/src/engine.ts`
- Modify: `packages/frontend-misskey-world-engine/src/envs/lobby.ts`
- Modify: `packages/frontend-misskey-world-engine/test/CelShadingRenderer.test.ts`
- Modify: `CHANGELOG.md`
- [ ] Add a RED constructor test using a real `Scene` and the default delegate factory; assert the registered Babylon renderer is disabled as an automatic mesh hook and configured with zero z-offsets. Register the Babylon runtime before creating the scene.
- [ ] Confirm RED because `RegisterOutlineRenderer` is not yet part of the package runtime setup.
- [ ] Add `BABYLON.RegisterOutlineRenderer()` to `registerBabylonRuntime()`.
- [ ] Give `WorldEngine` a public owned renderer so environment managers and future lobby systems can configure meshes without knowing its scheduling internals:
```ts
public readonly celShadingRenderer: CelShadingRenderer;
```
Instantiate it immediately after the scene is configured, with black and `cm(1)` defaults. Dispose it before `super.destroy()` tears down the Babylon engine and scene.
- [ ] In `LobbyEnvManager.load()`, exclude the skybox immediately after creation:
```ts
this.engine.celShadingRenderer.excludeMesh(this.skybox);
```
- [ ] Do not add avatar-specific registration. Avatar meshes use the scene defaults and therefore participate automatically when loaded.
- [ ] Add the user-visible Client changelog entry under `## Unreleased`:
```md
- Enhance: Worldのロビーでメッシュの輪郭線を表示するように
```
- [ ] Run the complete targeted renderer suite and confirm all tests pass:
```powershell
pnpm --filter frontend-misskey-world-engine test
```
Expected: all `CelShadingRenderer` tests pass with zero warnings/errors. Do not run typecheck, lint, build, or visual validation.
- [ ] Review the final diff for accidental locale/backend/Room changes and SPDX coverage:
```powershell
git diff --check
git status --short
git diff -- packages/frontend-misskey-world-engine CHANGELOG.md
```
- [ ] Commit:
```powershell
git add packages/frontend-misskey-world-engine/src/babylonRuntime.ts packages/frontend-misskey-world-engine/src/engine.ts packages/frontend-misskey-world-engine/src/envs/lobby.ts packages/frontend-misskey-world-engine/test/CelShadingRenderer.test.ts CHANGELOG.md
git commit -m "feat(world): enable cel outlines in lobby"
```
@@ -1,176 +0,0 @@
# World Cel-Shading Outline Design
## Summary
Add black mesh borders to the Misskey World lobby by rendering an inverted hull after the lobby's opaque and alpha-tested geometry has populated the depth buffer. The renderer reuses each original Babylon.js `SubMesh`; it does not create or retain duplicate outline meshes, and it does not change the original material, lighting, color, or shadow behavior.
The first integration is limited to `WorldEngine`, which currently hosts the lobby. `RoomEngine` and the avatar preview engines do not create the renderer. The renderer itself remains scene-agnostic so another engine can opt in later by constructing it for that engine's `Scene`.
## Goals
- Draw an outline around exterior silhouettes.
- Draw an outline at occlusion contours inside an object, such as a cat ear obscuring the cat's head.
- Draw the same kind of contour when the foreground and background geometry belong to different Babylon.js meshes.
- Use an inverted-hull technique: expand vertices along their normals, cull front faces, and render the remaining back faces in black.
- Re-render the original `SubMesh` data instead of creating outline meshes.
- Allow scene defaults and per-mesh overrides for enabled state, color, and width.
- Automatically include meshes added to the lobby after initialization, including player avatars.
- Keep the implementation reusable by other World scenes.
- Preserve Babylon.js WebGPU snapshot rendering and avoid adding outlines to shadow, glow, reflection, or other auxiliary render passes.
## Non-goals
- Quantizing lighting, changing shadows, or changing surface colors.
- Detecting a visible crease solely because adjacent surface normals differ. A cube edge with no foreground/background depth relationship is not guaranteed to receive a line.
- Supporting alpha-blended transparent geometry in the initial implementation.
- Supporting lines, points, sprites, Gaussian splats, or other non-triangle primitives.
- Supporting arbitrary custom vertex displacement performed by `ShaderMaterial`, `NodeMaterial`, or another custom shader without an explicit future adapter.
- Giving different outline settings to individual instances within one hardware-instanced draw.
- Keeping outline width constant in screen pixels.
## Terminology
A Babylon.js `Mesh` owns geometry, transforms, skeleton and morph state, and a material or `MultiMaterial`. A `SubMesh` is an index range within a mesh and is the unit Babylon.js submits as a draw call. A mesh commonly has one submesh; a mesh using multiple material slots commonly has several. The outline renderer schedules and replays submeshes because that preserves their exact index ranges and material-specific alpha-test behavior. User-facing outline configuration remains mesh-scoped and applies to all submeshes rendered by that mesh.
## Why Babylon.js `renderOutline` Is Not Used
Babylon.js's built-in outline scheduling draws its colored expanded pass before the original mesh. The original mesh then overwrites contours that fall over deeper parts of that same mesh, leaving primarily the exterior silhouette. The requested cat-ear-over-head contour needs the complete opaque depth buffer to exist before the expanded hull is colored.
The new component reuses a disabled Babylon.js `OutlineRenderer` as a low-level submesh draw delegate, including its support for bones, morph targets, baked vertex animation, alpha testing, and instances. It calls the delegate's public `render()` method itself and must not use the built-in `mesh.renderOutline = true` scheduling path. The World component owns collection, ordering, culling, state restoration, and eligibility. `registerBabylonRuntime()` registers `OutlineRenderer`, and the World component sets the delegate's `enabled` flag to `false` so only the new scheduler can invoke it.
## Architecture
### `CelShadingRenderer`
Create a reusable `CelShadingRenderer` owned by one Babylon.js `Scene`. Its constructor accepts scene defaults:
```ts
export type CelShadingOptions = {
enabled: boolean;
color: BABYLON.Color3;
width: number;
};
new CelShadingRenderer(scene, {
enabled: true,
color: BABYLON.Color3.Black(),
width: cm(1),
});
```
`width` is expressed in Misskey World units, where `cm(1)` represents one centimetre. Before drawing, the renderer converts this value into the rendering mesh's local scale so lobby geometry baked into centimetres and avatar geometry beneath a `WORLD_SCALE` transform receive approximately the same world-space thickness. It decomposes the rendering mesh's world matrix, takes the largest absolute scale component, and passes `width / maxScale` to the low-level outline effect. Uniform scaling is handled exactly. With non-uniform scaling, choosing the largest component prevents the outline from becoming thicker than requested but allows it to be thinner along the other axes.
The renderer exposes mesh-level configuration without using `metadata`:
```ts
renderer.setMeshOptions(mesh, {
enabled: true,
color: new BABYLON.Color3(0.1, 0.1, 0.1),
width: cm(0.6),
});
renderer.clearMeshOptions(mesh);
renderer.excludeMesh(mesh);
renderer.includeMesh(mesh);
```
Overrides are held in a `WeakMap<BABYLON.Mesh, Partial<CelShadingOptions>>`. Exclusion is represented by an `enabled: false` override. All submeshes and hardware instances in the rendering mesh's batch share the resolved mesh settings.
### Collection and replay
For the main render pass only, the component observes the per-submesh render stage and records each eligible opaque or alpha-tested draw. Entries are deduplicated so a depth prepass and the normal opaque pass do not cause two outline draws.
For each Babylon.js rendering group, the component replays the collected entries after that group's opaque and alpha-test queues have rendered and before its particle and transparent queues. It flushes from `Scene.onBeforeParticlesRenderingObservable` when the group has particles and uses a chained `RenderingGroup.onBeforeTransparentRendering` callback as the fallback when it does not. The existing rendering-group callback is always preserved. At this point the depth buffer contains both sides of a self-occlusion relationship. The ear's expanded back faces can therefore pass the depth test over the deeper head while remaining hidden over the nearer ear surface.
The replay uses these states:
- color write: enabled;
- depth test: enabled using the scene's compatible comparison;
- depth write: disabled;
- alpha blending: disabled;
- face culling: enabled, with front faces culled;
- winding: adjusted for the effective material orientation and negative world transforms;
- polygon depth offset: zero; hull expansion and the completed depth buffer determine visibility;
- outline color: resolved mesh color, black by default;
- vertex offset: resolved mesh width converted to local scale.
Before calling the low-level delegate, the scheduler temporarily sets the rendering mesh's Babylon.js `outlineColor` and `outlineWidth` fields to the resolved values and restores them afterward. The delegate's `zOffset` and `zOffsetUnits` are both configured as zero. Every modified mesh and engine state is saved and restored in a `try`/`finally` boundary. A submesh whose effect is not ready is skipped for that frame without preventing later entries or normal scene rendering. Queues are cleared at rendering-group boundaries and on errors.
### Main-pass isolation
Collection and replay occur only when `engine.currentRenderPassId` matches the active camera's output-target render pass or camera render pass. Babylon 9.19 assigns a dedicated render pass ID to the main camera instead of leaving the main scene on `Constants.RENDERPASS_MAIN`. Comparing against the active camera prevents outlines from leaking into `GlowLayer`, shadow maps, reflection probes, screenshots' auxiliary targets, or other object renderers. The renderer is installed before `SnapshotRenderingHelper.enableSnapshotRendering()` so its stable draw sequence can participate in the WebGPU snapshot capture. Existing World flows that disable snapshot rendering while adding or removing avatars continue to bracket those changes.
### FAST Snapshot replay synchronization
The renderer allocates one stable render-pass ID for the outline delegate's `SubMesh` draw wrappers and supplies it explicitly to every outline draw. FAST Snapshot Rendering records those draws once and later skips the normal mesh stages, so the renderer observes the main before-draw phase and updates existing outline wrappers without issuing additional draws. For every recorded wrapper it binds that wrapper's WebGPU `LeftOver` data buffer and writes the current `viewProjection`, effective `world`, local outline width, and resolved color before Babylon replays the GPU bundle. This mirrors Babylon's snapshot handling for effect-layer draw wrappers while keeping the cel-shading pass independent from `SnapshotRenderingHelper` internals.
### Rendering-group hook ownership
The transparent fallback is owned per `RenderingGroup` object, not per numeric rendering-group ID. A scene, GlowLayer, shadow/render target, or other `RenderingManager` can each own a different group object with the same ID. Keying only by ID would repeatedly wrap the callback when managers alternate and eventually create an unbounded callback chain. Object-identity ownership installs one wrapper per actual group, preserves that group's prior callback, and restores every still-owned callback during disposal.
### Lobby integration and lifecycle
`WorldEngine` constructs and owns the renderer before loading `LobbyEnvManager`. This makes imported lobby meshes and player avatars eligible without per-loader registration. `WorldEngine.destroy()` disposes the renderer before the scene is disposed.
`LobbyEnvManager` explicitly excludes its skybox. Invisible collision meshes are never submitted to the main color pass and therefore are not collected. Additional presentation-only meshes can call `excludeMesh()` when their visual result is unsuitable.
Neither `RoomEngine` nor avatar preview engines construct `CelShadingRenderer`. Future scene support consists of creating the same component for that scene and applying scene-specific exclusions.
## Eligibility and Compatibility
An entry is eligible only when all of the following are true:
- the current render pass matches the active camera's output-target or camera render pass;
- the rendering object is a triangle `BABYLON.Mesh` with a material;
- position and normal vertex buffers are present;
- the material does not require alpha blending for the effective mesh;
- resolved mesh options have `enabled: true` and `width > 0`;
- the mesh has not been disposed;
- the submesh belongs to the rendering group currently being collected.
Opaque `PBRMaterial`, `StandardMaterial`, and `MultiMaterial` submeshes are supported. Alpha-tested materials are supported by binding their alpha-test texture, UV selection, texture matrix, and cutoff behavior in the outline effect. Standard Babylon.js bones, morph targets, baked vertex animation, regular instances, and thin instances follow the same feature defines and bindings as the built-in outline effect.
The initial implementation automatically skips alpha-blended materials because their normal pass does not provide a reliable nearest-surface depth buffer. It also skips meshes without normals. Open or two-sided meshes may produce missing contours because an inverted hull relies on back-facing triangles. Hard-normal seams can produce gaps or spikes when displaced, and extreme widths can expose concave portions of the hull. Custom vertex shaders that move vertices will not match unless their deformation is added to the outline effect in a future adapter.
## Performance
Each eligible visible submesh adds one extra draw call and repeats its vertex deformation work. The fragment shader is a constant color and most interior fragments fail the depth test, but skinned and morphing avatars still pay an additional vertex cost. No duplicate vertex or index buffers are retained.
Per-mesh options do not create shader variants for color or width; both are uniforms. Feature variants are limited to geometry requirements already needed by the original submesh, such as bones, morph targets, instances, and alpha testing. Small or visually noisy meshes can be excluded, following the reference artwork's practice of omitting outlines from small details.
## Failure Handling and State Safety
- Unsupported meshes are skipped rather than partially rendered.
- A shader compilation or readiness delay skips only that submesh for the frame.
- Render queues are cleared even when a replay throws.
- Engine depth, culling, color-write, alpha, stencil, z-offset, and render-pass state are restored before control returns to Babylon.js.
- Disposal removes observers and rendering-group hooks where the installed callback is still owned by this component, releases render-pass identifiers, and clears strong references to queued submeshes.
## Testing
Add focused tests for the renderer's policy and orchestration using a fake outline draw delegate so tests do not require a browser WebGPU adapter:
1. defaults are resolved and mesh overrides win;
2. exclusion and re-inclusion work;
3. opaque and alpha-tested submeshes are collected;
4. alpha-blended, missing-normal, disposed, non-main-pass, and zero-width entries are skipped;
5. repeated collection of the same draw is deduplicated;
6. replay happens only after collection and is separated by rendering group;
7. all collected eligible entries are attempted even if one is not ready;
8. engine state and queues are restored when a draw throws;
9. negative transforms select the correct front-face winding;
10. width conversion gives matching world-space thickness for lobby-scale and `WORLD_SCALE` avatar transforms.
Run only the targeted automated tests for this renderer. Full-repository or full-package type checks, lint, and visual validation are intentionally excluded from this change's verification because the surrounding World implementation is under active development and already contains unrelated errors.
## Acceptance Criteria
- The World lobby displays black inverted-hull borders without changing surface lighting, shadows, or colors.
- An occluding foreground feature such as an avatar ear draws a line where it overlaps a deeper part such as the head.
- The result works whether the foreground and background are in one mesh, separate submeshes, or separate meshes in the same completed depth buffer.
- No outline mesh is created or retained.
- Meshes added after initialization, including lobby avatars, receive default outlines automatically.
- A caller can disable outlining and override color or width per mesh.
- The lobby skybox and unsupported alpha-blended or non-triangle content are not outlined.
- Room and avatar preview scenes remain unchanged.
- Auxiliary render passes remain unchanged.
- Targeted renderer tests pass.
-475
View File
@@ -1414,8 +1414,6 @@ viewRenotedChannel: "リノート先のチャンネルを見る"
previewingTheme: "テーマのプレビュー中"
previewingThemeRestore: "元に戻す"
accessToken: "アクセストークン"
choose: "選択"
rotate: "回転"
chooseEmojiPalette: "絵文字パレットを選択"
addToEmojiPalette: "絵文字パレットに追加"
emojiPaletteAlreadyAddedConfirm: "この絵文字はすでにこの絵文字パレットに含まれています。追加しなおしますか?"
@@ -3583,476 +3581,3 @@ _qr:
scanFile: "端末の画像をスキャン"
raw: "テキスト"
mfm: "MFM"
worldAvatar: "Worldアバター"
worldAvatar_description: "MisskeyWorld / MisskeyRoomsで使用可能な3Dアバターを作成できます。"
_miWorld:
separateRenderingThread: "描画を別スレッドに分離"
separateRenderingThread_description: "有効にするとパフォーマンスが向上します。不安定になる場合は無効すると改善する可能性があります。"
graphicsQuality: "グラフィックの品質"
graphicsSettings: "グラフィック設定"
frameRateLimitation: "フレームレート制限"
higherValuePerformanceNote: "高くすると体験が向上しますが、消費電力が増加するなどパフォーマンスに影響を与えます。"
resolution: "解像度"
fov: "視野角"
failedToInitialize: "初期化に失敗しました"
crushed_description: "描画が継続できなくなりました。デバイスのリソース不足の可能性が考えられます。"
antialiasing: "アンチエイリアス"
avatar: "アバター"
advancedCustomize: "高度なアレンジ"
attachAccessory: "アクセサリーをつける"
takeScreenShot: "スクリーンショット"
onlineMenu: "オンラインメニュー"
connectToOnline: "オンラインに接続"
disconnectToOnline: "オンラインから切断"
character: "キャラクター"
sit: "座る"
lyingDown: "寝そべる"
standUp: "立ち上がる"
showUsernameOnAvatar: "アバターにユーザー名を表示"
show2dAvatarOnAvatar: "アバターにユーザーアイコンを表示"
_avatars:
_default:
body: "ボディ"
eyes: "目"
mouth: "口"
_avatarAccessories:
mug: "マグカップ"
_mug:
bodyMat: "コップの素材"
liquidMat: "液体の素材"
mikan: "みかん"
bolt: "ボルト"
_bolt:
mat: "素材"
_miRoom:
snapToGrid: "グリッドにスナップ"
gridScale: "グリッドサイズ"
thereAreUnsavedChanges: "未保存の変更があります"
revertAllChangesConfirmation: "全ての変更を取り消し、部屋を最後に保存した状態まで戻しますか?"
yourDeviceNotSupported_title: "MisskeyRoomを起動できません"
yourDeviceNotSupported_description: "お使いのデバイスがMisskeyRoomをサポートしていないか、デバイスのリソース不足などにより一時的に利用できなくなっています。\nMisskeyRoomを動作させるには、WebGPUをサポートするデバイス・ブラウザが必要です。"
imageFit: "画像のはめ込み"
imageFit_cover: "覆う"
imageFit_contain: "収める"
imageFit_stretch: "伸縮"
material_metallic: "光沢"
material_roughness: "粗さ"
light_brightness: "明るさ"
advancedCustomize: "高度なアレンジ"
enterEditMode: "エディットモードを始める"
exitEditMode: "エディットモードを終了"
installFurniture: "家具を設置"
roomCustomize: "部屋のカスタマイズ"
changeRoomName: "ルーム名を編集"
roomInfo: "ルーム情報"
duplicate: "複製"
grab: "掴む"
furnitureCustomize: "家具のアレンジ"
uninstallFurniture: "しまう"
furnituresCount: "家具の数"
attachedFilesCount: "添付ファイル数"
_furniturePlacement:
top: "上面設置"
bottom: "下面設置"
side: "側面設置"
_furnitures:
haniwa: "はにわ"
_haniwa:
bodyMat: "本体の素材"
insideColor: "中身の色"
woodRingFloorLamp: "リングシェードフロアランプ"
_woodRingFloorLamp:
shadeMat: "シェードの素材"
bodyMat: "本体の素材"
light: "照明"
a4Case: "A4ケース"
_a4Case:
mat: "素材"
aircon: "エアコン"
allInOnePc: "一体型PC"
_allInOnePc:
bezelMat: "ベゼルの素材"
bodyMat: "本体の素材"
image: "画面の画像"
image_desktop: "デスクトップ"
screenBrightness: "画面の明るさ"
aquarium: "水槽"
aromaReedDiffuser: "アロマリードディフューザー"
_aromaReedDiffuser:
bottleMat: "ボトルの素材"
oilMat: "オイルの素材"
banknote: "紙幣"
beamLamp: "ビームランプ"
bed: "ベッド"
_bed:
frameMat: "フレームの素材"
blind: "ブラインド"
_blind:
angle: "羽根の回転角度"
blades: "羽根の枚数"
open: "開閉状態"
book: "本"
_book:
height: "高さ"
thickness: "厚み"
variation: "バリエーション"
width: "幅"
books: "本の束"
_books:
variation: "バリエーション"
boxWallShelf: "ボックス型ウォールシェルフ"
_boxWallShelf:
bodyMat: "本体の素材"
height: "高さ"
width: "幅"
withBack: "背板"
cactusS: "サボテン S"
_cactusS:
potMat: "鉢の素材"
cardboardBox: "段ボール箱"
_cardboardBox:
variation: "種類"
variation_aizon: "Aizon"
variation_default: "デフォルト"
variation_mikan: "みかん"
ceilingFanLight: "シーリングファンライト"
_ceilingFanLight:
shadeMat: "シェードの素材"
bodyMat: "本体の素材"
ceilingFan: "シーリングファン"
_ceilingFan:
shadeMat: "シェードの素材"
bodyMat: "本体の素材"
chair: "椅子"
_chair:
primaryMat: "メインの素材"
secondaryMat: "サブの素材"
frameMat: "フレームの素材"
clippedPicture: "留められた写真"
_clippedPicture:
height: "高さ"
image: "画像"
width: "幅"
coffeeCup: "コーヒーカップ"
colorBox: "カラーボックス"
_colorBox:
mat: "素材"
cuboid: "直方体"
_cuboid:
mat: "素材"
x: "X"
y: "Y"
z: "Z"
cupNoodle: "インスタントラーメン"
curtain: "カーテン"
custardPudding: "プリン"
descriptionPlate: "説明が書かれたプレート"
desk: "デスク"
_desk:
boardMat: "天板の素材"
depth: "奥行き"
frameMat: "フレームの素材"
width: "幅"
desktopPc: "デスクトップPC"
_desktopPc:
bodyMat: "本体の素材"
coverMat: "カバーの素材"
inner1Mat: "内部素材1"
inner2Mat: "内部素材2"
inner3Mat: "内部素材3"
ledColor: "LEDの色"
djMixer: "DJミキサー"
djPlayer: "DJプレーヤー"
_djPlayer:
image: "画像"
"image:waveform": "波形"
screenBrightness: "画面の明るさ"
ductRailSpotLights: "スポットライト付きダクトレール"
_ductRailSpotLights:
angleH: "水平角度"
angleV: "垂直角度"
bodyMat: "本体の素材"
light: "照明"
ductTape: "ガムテープ"
herbarium: "ハーバリウム"
electronicDisplayBoard: "電光掲示板"
_electronicDisplayBoard:
frameMat: "フレームの素材"
ledBrightness: "LEDの明るさ"
ledColor: "LEDの色"
text: "テキスト"
emptyBento: "空の弁当容器"
energyDrink: "エナジードリンク"
envelope: "封筒"
facialTissue: "ティッシュ"
glassCylinderPotPlant: "ガラスシリンダーの鉢植えと植物"
handheldGameConsole: "携帯ゲーム機"
_handheldGameConsole:
bodyMat: "本体の素材"
image: "画像"
screenBrightness: "画面の明るさ"
hangingDuctRail: "吊り下げダクトレール"
_hangingDuctRail:
bodyMat: "本体の素材"
height: "高さ"
width: "幅"
hangingTShirt: "吊り下げTシャツ"
icosahedron: "正二十面体のオブジェ"
_icosahedron:
mat: "素材"
ironFrameTable: "アイアンフレームテーブル"
_ironFrameTable:
boardMat: "天板の素材"
depth: "奥行き"
frameMat: "フレームの素材"
height: "高さ"
width: "幅"
issyoubin: "一升瓶"
_issyoubin:
variation: "種類"
keyboard: "キーボード"
_keyboard:
bodyMat: "本体の素材"
keyMat: "キーの素材"
laptopPc: "ノートPC"
_laptopPc:
bezelMat: "ベゼルの素材"
bodyMat: "本体の素材"
image: "画像"
openAngle: "開き具合"
screenBrightness: "画面の明るさ"
largeMousepad: "大きいマウスパッド"
_largeMousepad:
image: "画像"
lavaLamp: "ラバランプ"
_lavaLamp:
bodyMat: "本体の素材"
glassMat: "ガラスの素材"
lavaColor: "オイルの色"
lightColor: "ランプの色"
letterCase: "レターケース"
lowPartitionBar: "低いパーティションバー"
_lowPartitionBar:
bodyMat: "本体の素材"
width: "幅"
miObjet: "Miオブジェ"
miPlate: "Miプレート"
miPlateDisplayed: "飾られたMiプレート"
milk: "牛乳"
mixer: "ミキサー"
monitor: "モニター"
_monitor:
bodyMat: "本体の素材"
image: "画像"
screenBrightness: "画面の明るさ"
monitorSpeaker: "モニタースピーカー"
_monitorSpeaker:
mat: "素材"
monstera: "モンステラ"
_monstera:
potMat: "鉢の素材"
mug: "マグカップ"
_mug:
bodyMat: "コップの素材"
liquidMat: "液体の素材"
newtonsCradle: "ニュートンクレードル"
_newtonsCradle:
frameMat: "フレームの素材"
openedCardboardBox: "開いた段ボール箱"
pachira: "パキラ"
_pachira:
potMat: "鉢の素材"
petBottle: "ペットボトル"
_petBottle:
empty: "空"
variation: "種類"
variation_greenTea: "緑茶"
variation_mineralWater: "ミネラルウォーター"
withCap: "キャップ"
withLabel: "ラベル"
piano: "ピアノ"
_piano:
bodyMat: "本体の素材"
pictureFrame: "シンプルな額縁"
_pictureFrame:
depth: "厚さ"
frameMat: "フレームの素材"
frameThickness: "フレームの幅"
height: "高さ"
image: "画像"
matHThickness: "マットの横幅"
matVThickness: "マットの縦幅"
width: "幅"
withCover: "カバーあり"
pizza: "ピザ"
plant: "植物"
plant2: "植物 2"
poster: "ポスター"
_poster:
height: "高さ"
image: "画像"
width: "幅"
powerStrip: "電源タップ"
radiometer: "ラジオメーター"
randomBooks: "雑多な本"
_randomBooks:
count: "数"
seed: "シード"
stackVertically: "平積み"
variation: "バリエーション"
variation_mix: "いろいろ"
variation_mixPlain: "いろいろ(無地)"
recordPlayer: "レコードプレーヤー"
rolledUpPoster: "丸めたポスター"
roundRug: "円形のラグ"
router: "ルーター"
siphon: "サイフォン"
snakeplant: "サンセベリア"
_snakeplant:
potMat: "鉢の素材"
sofa: "ソファ"
_sofa:
bodyMat: "本体の素材"
speaker: "スピーカー"
_speaker:
innerMat: "内側の素材"
outerMat: "外側の素材"
speakerStand: "スピーカースタンド"
_speakerStand:
bodyMat: "本体の素材"
height: "高さ"
spotLight: "スポットライト"
_spotLight:
angleH: "横方向の角度"
angleV: "縦方向の角度"
bodyMat: "本体の素材"
light: "照明"
downlight: "ダウンライト"
_downlight:
bodyMat: "本体の素材"
light: "照明"
sprayer: "霧吹き"
stanchionPole: "スタンションポール"
_stanchionPole:
bodyMat: "本体の素材"
ropeMat: "ロープの素材"
steelRack: "スチールラック"
_steelRack:
height: "高さ"
numberOfShelfs: "シェルフの数"
poleMat: "ポールの素材"
shelfPositionOf: "シェルフの位置"
shelfMat: "シェルフの素材"
widthAndDepthVariation: "W x D"
stormGlass: "ストームグラス"
tableSalt: "食卓塩"
tabletopCalendar: "卓上カレンダー"
tabletopDigitalClock: "卓上デジタル時計"
_tabletopDigitalClock:
bodyMat: "本体の素材"
lcdColor: "LCDの色"
tabletopFlag: "卓上旗"
_tabletopFlag:
image: "画像"
tabletopGlassPictureFrame: "卓上ガラス製フォトフレーム"
_tabletopGlassPictureFrame:
height: "高さ"
image: "画像"
width: "幅"
tabletopIronFrameStand: "卓上アイアンフレームスタンド"
_tabletopIronFrameStand:
boardMat: "板の素材"
depth: "奥行き"
frameMat: "フレームの素材"
height: "高さ"
width: "幅"
tabletopLcdButtonsController: "LCDボタン付き卓上コントローラー"
_tabletopLcdButtonsController:
bodyMat: "本体の素材"
image: "画像"
screenBrightness: "画面の明るさ"
tabletopPictureFrame: "卓上フォトフレーム"
_tabletopPictureFrame:
depth: "厚さ"
frameMat: "フレームの素材"
frameThickness: "フレームの幅"
height: "高さ"
image: "画像"
matHThickness: "マットの横幅"
matVThickness: "マットの縦幅"
width: "幅"
tapestry: "タペストリー"
_tapestry:
height: "高さ"
image: "画像"
width: "幅"
tetrapod: "波消ブロックの置物"
tv: "テレビ"
_tv:
bodyMat: "本体の素材"
screenBrightness: "画面の明るさ"
twistedCubeObjet: "ねじれた立方体のオブジェ"
usedTissue: "使用済みティッシュ"
wallCanvas: "壁掛けキャンバス"
_wallCanvas:
height: "高さ"
image: "画像"
width: "幅"
wallClock: "壁掛け時計"
_wallClock:
frameMat: "フレームの素材"
faceMat: "文字盤の素材"
handsMat: "針の素材"
wallGlassPictureFrame: "ガラスの壁掛けフォトフレーム"
_wallGlassPictureFrame:
height: "高さ"
image: "画像"
width: "幅"
wallMirror: "壁掛けミラー"
_wallMirror:
frameMat: "フレームの素材"
frameThickness: "フレームの厚み"
height: "高さ"
width: "幅"
wallMountSpotLight: "ウォールマウントスポットライト"
_wallMountSpotLight:
angleH: "横方向の角度"
angleV: "縦方向の角度"
bodyMat: "本体の素材"
light: "照明"
wallShelf: "ウォールシェルフ"
_wallShelf:
boardMat: "板の素材"
boardStyle: "板のスタイル"
"boardStyle:color": "単色"
"boardStyle:wood": "木目"
style: "スタイル"
wireBasket: "ワイヤーバスケット"
_wireBasket:
bodyMat: "本体の素材"
wireNet: "ワイヤーネット"
_wireNet:
bodyMat: "本体の素材"
woodRingsPendantLight: "リングペンダントライト"
_woodRingsPendantLight:
bodyMat: "本体の素材"
length: "長さ"
light: "照明"
shadeMat: "シェードの素材"
woodSoundAbsorbingPanel: "木製吸音パネル"
ironFrameShelf: "アイアンフレームシェルフ"
_ironFrameShelf:
boardMat: "板の素材"
frameMat: "フレームの素材"
height: "段数"
width: "幅"
kakejiku: "掛軸"
_kakejiku:
image: "画像"
"image:kitsuaishinsei": "喫藍心整"
+1 -3
View File
@@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "2026.7.0",
"version": "2026.8.0-alpha.0",
"codename": "nasubi",
"repository": {
"type": "git",
@@ -20,8 +20,6 @@
"packages/misskey-js/generator",
"packages/misskey-reversi",
"packages/misskey-bubble-game",
"packages/misskey-world",
"packages/frontend-misskey-world-engine",
"packages-private/*"
],
"private": true,
@@ -1,28 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class WorldRoom1778744540138 {
name = 'WorldRoom1778744540138'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "world_room" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "description" character varying(1024) NOT NULL, "userId" character varying(32) NOT NULL, "likedCount" integer NOT NULL DEFAULT '0', "visibility" character varying(128) NOT NULL DEFAULT 'public', "def" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_40cfacaf35b0b54bb2281c89767" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_88289375952050da4a7752a366" ON "world_room" ("updatedAt") `);
await queryRunner.query(`CREATE INDEX "IDX_f803a5efb4125c5fd8a414285e" ON "world_room" ("userId") `);
await queryRunner.query(`ALTER TABLE "world_room" ADD CONSTRAINT "FK_f803a5efb4125c5fd8a414285ed" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "world_room" DROP CONSTRAINT "FK_f803a5efb4125c5fd8a414285ed"`);
await queryRunner.query(`DROP INDEX "public"."IDX_f803a5efb4125c5fd8a414285e"`);
await queryRunner.query(`DROP INDEX "public"."IDX_88289375952050da4a7752a366"`);
await queryRunner.query(`DROP TABLE "world_room"`);
}
}
@@ -1,30 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class WorldAvatar1779921322355 {
name = 'WorldAvatar1779921322355'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "world_avatar" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "userId" character varying(32) NOT NULL, "def" jsonb NOT NULL DEFAULT '{}', "active" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_e7a27262285cc2c27114871f866" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_0f1d0bdfaca455cc2f13defabe" ON "world_avatar" ("updatedAt") `);
await queryRunner.query(`CREATE INDEX "IDX_4eba43c8e2540a92e99dd7f5a9" ON "world_avatar" ("userId") `);
await queryRunner.query(`ALTER TABLE "world_room" ADD "accessCount" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "world_avatar" ADD CONSTRAINT "FK_4eba43c8e2540a92e99dd7f5a9a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "world_avatar" DROP CONSTRAINT "FK_4eba43c8e2540a92e99dd7f5a9a"`);
await queryRunner.query(`ALTER TABLE "world_room" DROP COLUMN "accessCount"`);
await queryRunner.query(`DROP INDEX "public"."IDX_4eba43c8e2540a92e99dd7f5a9"`);
await queryRunner.query(`DROP INDEX "public"."IDX_0f1d0bdfaca455cc2f13defabe"`);
await queryRunner.query(`DROP TABLE "world_avatar"`);
}
}
+6 -36
View File
@@ -18,7 +18,7 @@ import { FlashService } from '@/core/FlashService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
import { SensitiveMediaDetectionService } from './SensitiveMediaDetectionService.js';
import { AnnouncementService } from './AnnouncementService.js';
import { AntennaService } from './AntennaService.js';
import { AchievementService } from './AchievementService.js';
@@ -154,11 +154,6 @@ import { ApQuestionService } from './activitypub/models/ApQuestionService.js';
import { QueueModule } from './QueueModule.js';
import { QueueService } from './QueueService.js';
import { LoggerService } from './LoggerService.js';
import { WorldRoomService } from './WorldRoomService.js';
import { WorldRoomEntityService } from './entities/WorldRoomEntityService.js';
import { WorldMultiplayService } from './WorldMultiplayService.js';
import { WorldAvatarService } from './WorldAvatarService.js';
import { WorldAvatarEntityService } from './entities/WorldAvatarEntityService.js';
import { TelemetryService } from './telemetry/TelemetryService.js';
import type { Provider } from '@nestjs/common';
@@ -169,7 +164,7 @@ const $AbuseReportService: Provider = { provide: 'AbuseReportService', useExisti
const $AbuseReportNotificationService: Provider = { provide: 'AbuseReportNotificationService', useExisting: AbuseReportNotificationService };
const $AccountMoveService: Provider = { provide: 'AccountMoveService', useExisting: AccountMoveService };
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useExisting: AccountUpdateService };
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
const $SensitiveMediaDetectionService: Provider = { provide: 'SensitiveMediaDetectionService', useExisting: SensitiveMediaDetectionService };
const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService };
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService };
@@ -236,9 +231,6 @@ const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatServic
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
const $PageService: Provider = { provide: 'PageService', useExisting: PageService };
const $WorldRoomService: Provider = { provide: 'WorldRoomService', useExisting: WorldRoomService };
const $WorldMultiplayService: Provider = { provide: 'WorldMultiplayService', useExisting: WorldMultiplayService };
const $WorldAvatarService: Provider = { provide: 'WorldAvatarService', useExisting: WorldAvatarService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@@ -294,8 +286,6 @@ const $RoleEntityService: Provider = { provide: 'RoleEntityService', useExisting
const $ReversiGameEntityService: Provider = { provide: 'ReversiGameEntityService', useExisting: ReversiGameEntityService };
const $MetaEntityService: Provider = { provide: 'MetaEntityService', useExisting: MetaEntityService };
const $SystemWebhookEntityService: Provider = { provide: 'SystemWebhookEntityService', useExisting: SystemWebhookEntityService };
const $WorldRoomEntityService: Provider = { provide: 'WorldRoomEntityService', useExisting: WorldRoomEntityService };
const $WorldAvatarEntityService: Provider = { provide: 'WorldAvatarEntityService', useExisting: WorldAvatarEntityService };
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useExisting: ApAudienceService };
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useExisting: ApDbResolverService };
@@ -327,7 +317,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AbuseReportNotificationService,
AccountMoveService,
AccountUpdateService,
AiService,
SensitiveMediaDetectionService,
AnnouncementService,
AntennaService,
AchievementService,
@@ -394,9 +384,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RegistryApiService,
ReversiService,
PageService,
WorldRoomService,
WorldMultiplayService,
WorldAvatarService,
ChartLoggerService,
FederationChart,
@@ -452,8 +439,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ReversiGameEntityService,
MetaEntityService,
SystemWebhookEntityService,
WorldRoomEntityService,
WorldAvatarEntityService,
ApAudienceService,
ApDbResolverService,
@@ -483,7 +468,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AbuseReportNotificationService,
$AccountMoveService,
$AccountUpdateService,
$AiService,
$SensitiveMediaDetectionService,
$AnnouncementService,
$AntennaService,
$AchievementService,
@@ -550,9 +535,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RegistryApiService,
$ReversiService,
$PageService,
$WorldRoomService,
$WorldMultiplayService,
$WorldAvatarService,
$ChartLoggerService,
$FederationChart,
@@ -608,8 +590,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ReversiGameEntityService,
$MetaEntityService,
$SystemWebhookEntityService,
$WorldRoomEntityService,
$WorldAvatarEntityService,
$ApAudienceService,
$ApDbResolverService,
@@ -639,7 +619,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AbuseReportNotificationService,
AccountMoveService,
AccountUpdateService,
AiService,
SensitiveMediaDetectionService,
AnnouncementService,
AntennaService,
AchievementService,
@@ -706,9 +686,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RegistryApiService,
ReversiService,
PageService,
WorldRoomService,
WorldMultiplayService,
WorldAvatarService,
FederationChart,
NotesChart,
@@ -763,8 +740,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ReversiGameEntityService,
MetaEntityService,
SystemWebhookEntityService,
WorldRoomEntityService,
WorldAvatarEntityService,
ApAudienceService,
ApDbResolverService,
@@ -794,7 +769,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AbuseReportNotificationService,
$AccountMoveService,
$AccountUpdateService,
$AiService,
$SensitiveMediaDetectionService,
$AnnouncementService,
$AntennaService,
$AchievementService,
@@ -860,9 +835,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RegistryApiService,
$ReversiService,
$PageService,
$WorldRoomService,
$WorldMultiplayService,
$WorldAvatarService,
$FederationChart,
$NotesChart,
@@ -917,8 +889,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$ReversiGameEntityService,
$MetaEntityService,
$SystemWebhookEntityService,
$WorldRoomEntityService,
$WorldAvatarEntityService,
$ApAudienceService,
$ApDbResolverService,
+5 -5
View File
@@ -16,12 +16,12 @@ import probeImageSize from 'probe-image-size';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import * as blurhash from 'blurhash';
import { createTempDir } from '@/misc/create-temp.js';
import { AiService } from '@/core/AiService.js';
import { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
import type { Prediction } from '@/core/AiService.js';
import type { Prediction } from '@/core/SensitiveMediaDetectionService.js';
export type FileInfo = {
size: number;
@@ -54,7 +54,7 @@ export class FileInfoService {
private logger: Logger;
constructor(
private aiService: AiService,
private sensitiveMediaDetectionService: SensitiveMediaDetectionService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('file-info');
@@ -266,7 +266,7 @@ export class FileInfoService {
fs.promises.unlink(path);
}
}
const predictions = await this.aiService.detectSensitiveMany(frameBuffers);
const predictions = await this.sensitiveMediaDetectionService.detectSensitiveMany(frameBuffers);
const results = predictions.filter((x): x is Prediction[] => x != null).map(x => judgePrediction(x));
// 判定に成功したフレームが 0 件のとき(接続先未設定・通信失敗等)は、
// Math.ceil(0) との比較が 0 >= 0 で真になり全動画がセンシティブ扱いになってしまうため、
@@ -291,7 +291,7 @@ export class FileInfoService {
.flatten({ background: { r: 119, g: 119, b: 119 } }) // 透過部分を18%グレーで塗りつぶす
.png()
.toBuffer();
const result = await this.aiService.detectSensitive(png);
const result = await this.sensitiveMediaDetectionService.detectSensitive(png);
if (result) {
[sensitive, porn] = judgePrediction(result);
}
@@ -173,16 +173,6 @@ export interface ChatEventTypes {
};
}
export interface WorldEventTypes {
enter: {
user: Packed<'UserLite'>;
avatar: Packed<'WorldAvatarLite'>['def'] | null;
};
left: {
userId: MiUser['id'];
};
}
export interface ReversiEventTypes {
matched: {
game: Packed<'ReversiGameDetailed'>;
@@ -325,10 +315,6 @@ export type GlobalEvents = {
name: `chatRoomStream:${MiChatRoom['id']}`;
payload: EventTypesToEventPayload<ChatEventTypes>;
};
world: {
name: `worldStream:${string}`;
payload: EventTypesToEventPayload<WorldEventTypes>;
};
reversi: {
name: `reversiStream:${MiUser['id']}`;
payload: EventTypesToEventPayload<ReversiEventTypes>;
@@ -449,9 +435,4 @@ export class GlobalEventService {
public publishReversiGameStream<K extends keyof ReversiGameEventTypes>(gameId: MiReversiGame['id'], type: K, value?: ReversiGameEventTypes[K]): void {
this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value);
}
@bindThis
public publishWorldStream<K extends keyof WorldEventTypes>(spaceKey: string, type: K, value?: WorldEventTypes[K]): void {
this.publish(`worldStream:${spaceKey}`, type, typeof value === 'undefined' ? null : value);
}
}
@@ -69,7 +69,7 @@ function isDetectImagesResponse(v: unknown): v is DetectImagesResponse {
const DETECT_IMAGES_PATH = 'v1/detect-images';
@Injectable()
export class AiService {
export class SensitiveMediaDetectionService {
private logger: Logger;
constructor(
@@ -79,7 +79,7 @@ export class AiService {
private httpRequestService: HttpRequestService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('ai');
this.logger = this.loggerService.getLogger('sensitive-media-detection');
}
/**
@@ -1,148 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import {
MiDriveFile,
MiWorldAvatar,
} from '@/models/_.js';
import type { DriveFilesRepository, WorldAvatarsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import type { MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { QueryService } from '@/core/QueryService.js';
@Injectable()
export class WorldAvatarService {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.worldAvatarsRepository)
private worldAvatarsRepository: WorldAvatarsRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
private queryService: QueryService,
private idService: IdService,
) {
}
public defaultAvatar = {
type: 'default',
body: {
color: [0.8, 0.8, 0.8],
roughness: 1,
metallic: 0,
},
eyes: {
type: 'a',
color: [0, 0, 0],
},
mouth: {
type: 'a',
color: [0, 0, 0],
},
accessories: [],
} satisfies MiWorldAvatar['def'];
@bindThis
public async validateDef(
me: MiUser,
def: MiWorldAvatar['def'],
): Promise<boolean> {
// TODO
return true;
}
@bindThis
public async findMyAvatarById(userId: MiUser['id'], avatarId: MiWorldAvatar['id']) {
return this.worldAvatarsRepository.findOneBy({ id: avatarId, userId: userId });
}
@bindThis
public async findAvatarById(avatarId: MiWorldAvatar['id']) {
return this.worldAvatarsRepository.findOne({ where: { id: avatarId }, relations: { user: true } });
}
@bindThis
public async getMyAvatarsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiWorldAvatar['id'] | null, untilId?: MiWorldAvatar['id'] | null) {
const query = this.queryService.makePaginationQuery(this.worldAvatarsRepository.createQueryBuilder('avatar'), sinceId, untilId)
.andWhere('avatar.userId = :userId', { userId });
const avatars = await query.take(limit).getMany();
return avatars;
}
@bindThis
public async create(
me: MiUser,
body: Partial<MiWorldAvatar>,
): Promise<MiWorldAvatar> {
const currentAvatarsCount = await this.worldAvatarsRepository.countBy({ userId: me.id });
// TODO: limit by role policy
const avatar = await this.worldAvatarsRepository.insertOne(new MiWorldAvatar({
id: this.idService.gen(),
updatedAt: new Date(),
name: body.name,
def: body.def,
userId: me.id,
active: currentAvatarsCount === 0,
}));
return avatar;
}
@bindThis
public async update(
avatar: MiWorldAvatar,
body: Partial<MiWorldAvatar>,
): Promise<void> {
body.updatedAt = new Date();
const updated = await this.worldAvatarsRepository.createQueryBuilder().update()
.set(body)
.where('id = :id', { id: avatar.id })
.returning('*')
.execute()
.then((response) => {
return response.raw[0];
});
if (body.active) {
await this.worldAvatarsRepository.createQueryBuilder().update()
.set({ active: false })
.where('userId = :userId', { userId: avatar.userId })
.andWhere('id != :id', { id: avatar.id })
.execute();
}
return updated;
}
@bindThis
public async delete(avatar: MiWorldAvatar, deleter?: MiUser): Promise<void> {
await this.worldAvatarsRepository.delete(avatar.id);
}
@bindThis
public async getActiveAvatarOfUser(userId: MiUser['id']) {
return this.worldAvatarsRepository.findOneBy({ userId, active: true });
}
@bindThis
public async getActiveAvatarOfUsers(userIds: MiUser['id'][]): Promise<MiWorldAvatar[]> {
if (userIds.length === 0) return [];
return this.worldAvatarsRepository.findBy({ userId: In(userIds), active: true });
}
}
@@ -1,144 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, Not } from 'typeorm';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import type { MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { QueryService } from '@/core/QueryService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { Packed } from '@/misc/json-schema.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
type PlayerState = {
position: [number, number, number],
rotation: [number, number, number],
sit?: string; // id
};
@Injectable()
export class WorldMultiplayService {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.redis)
private redisClient: Redis.Redis,
private roleService: RoleService,
private queryService: QueryService,
private idService: IdService,
private globalEventService: GlobalEventService,
private userEntityService: UserEntityService,
private worldAvatarService: WorldAvatarService,
private worldAvatarEntityService: WorldAvatarEntityService,
) {
}
@bindThis
public async enter(userId: MiUser['id'], spaceKey: string) {
// TODO: 同じユーザーが同時に複数のspacedに入れないようにする
// TODO: atomicにやる
const currentPlayers = await this.redisClient.hlen(`world:${spaceKey}:players`);
if (currentPlayers < 50) {
const redisPipeline = this.redisClient.pipeline();
redisPipeline.hset(`world:${spaceKey}:players`, userId, 1);
redisPipeline.hexpire(`world:${spaceKey}:players`, 30, 'FIELDS', 1, userId);
await redisPipeline.exec();
} else {
throw new Error('The group is full.');
}
// TODO: 既に入っていたらスキップ
const avatar = await this.worldAvatarService.getActiveAvatarOfUser(userId);
this.globalEventService.publishWorldStream(spaceKey, 'enter', {
user: await this.userEntityService.pack(userId),
avatar: avatar?.def,
});
}
@bindThis
public async heartbeat(userId: MiUser['id'], spaceKey: string) {
const exists = await this.redisClient.hexists(`world:${spaceKey}:players`, userId);
if (exists) {
const redisPipeline = this.redisClient.pipeline();
redisPipeline.hexpire(`world:${spaceKey}:players`, 30, 'FIELDS', 1, userId);
redisPipeline.hexpire(`world:${spaceKey}:playerStates`, 30, 'FIELDS', 1, userId);
await redisPipeline.exec();
} else {
throw new Error('Not in the group.');
}
}
@bindThis
public async left(userId: MiUser['id'], spaceKey: string) {
const redisPipeline = this.redisClient.pipeline();
redisPipeline.hdel(`world:${spaceKey}:players`, userId);
redisPipeline.hdel(`world:${spaceKey}:playerStates`, userId);
await redisPipeline.exec();
this.globalEventService.publishWorldStream(spaceKey, 'left', {
userId,
});
}
@bindThis
public async updatePlayerState(userId: MiUser['id'], spaceKey: string, state: PlayerState) {
const redisPipeline = this.redisClient.pipeline();
redisPipeline.hset(`world:${spaceKey}:playerStates`, userId, JSON.stringify(state));
redisPipeline.hexpire(`world:${spaceKey}:playerStates`, 30, 'FIELDS', 1, userId);
await redisPipeline.exec();
}
@bindThis
public async getPlayerStates(spaceKey: string): Promise<Record<string, PlayerState>> {
const entries = await this.redisClient.hgetall(`world:${spaceKey}:playerStates`);
return Object.fromEntries(Object.entries(entries).map(([userId, state]) => [userId, JSON.parse(state) as PlayerState]));
}
@bindThis
public getPlayerStatesAndHeatbeat(userId: MiUser['id'], spaceKey: string): Promise<Record<string, PlayerState>> {
// TODO: atomicにやる
this.heartbeat(userId, spaceKey);
return this.getPlayerStates(spaceKey);
}
@bindThis
public packPlayerProfile(user: Packed<'UserLite'>, avatar: Packed<'WorldAvatarLite'>['def'] | null) {
return {
user: {
name: user.name,
username: user.username,
avatarUrl: user.avatarUrl,
},
avatar: avatar ?? this.worldAvatarService.defaultAvatar,
};
}
@bindThis
public async getPlayerProfiles(spaceKey: string, userId?: MiUser['id']): Promise<Record<string, any>> {
let playerIds = await this.redisClient.hkeys(`world:${spaceKey}:players`);
playerIds = playerIds.filter(id => id !== userId);
const packedUsers = await this.userEntityService.packMany(playerIds);
const avatars = await this.worldAvatarService.getActiveAvatarOfUsers(playerIds);
const profiles: Record<string, any> = {};
for (const playerId of playerIds) {
const packedUser = packedUsers.find(u => u.id === playerId);
if (packedUser == null) continue;
profiles[playerId] = this.packPlayerProfile(packedUser, avatars.find(a => a.userId === playerId)?.def ?? null);
}
return profiles;
}
}
@@ -1,176 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import {
MiDriveFile,
MiWorldRoom,
} from '@/models/_.js';
import type { DriveFilesRepository, WorldRoomsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import type { MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { QueryService } from '@/core/QueryService.js';
const driveFileReferencingOptions = {
clippedPicture: ['image'],
tapestry: ['image'],
poster: ['image'],
pictureFrame: ['image'],
tabletopPictureFrame: ['image'],
tabletopGlassPictureFrame: ['image'],
wallCanvas: ['image'],
wallGlassPictureFrame: ['image'],
tabletopFlag: ['image'],
tabletopLcdButtonsController: ['image'],
djPlayer: ['image'],
monitor: ['image'],
allInOnePc: ['image'],
laptopPc: ['image'],
handheldGameConsole: ['image'],
largeMousepad: ['image'],
kakejiku: ['image'],
} as Record<string, string[]>;
@Injectable()
export class WorldRoomService {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.worldRoomsRepository)
private worldRoomsRepository: WorldRoomsRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
private queryService: QueryService,
private idService: IdService,
) {
}
@bindThis
public async validateDef(
me: MiUser,
def: MiWorldRoom['def'],
): Promise<boolean> {
// TODO: スキーマ検証(関係ないプロパティを入れたり不正な値を入れたりできないように)
// そのためにはJSON SchemaでRoomState/各objectのoptionsを定義する必要がある
const objectsLimit = 100; // TODO: ref role policy
if (def.installedFurnitures.length > objectsLimit) {
return false;
}
const attachedFilesLimit = 30; // TODO: ref role policy
const attachedFileIds = this.collectReferencedDriveFileIds(def);
if (attachedFileIds.size > attachedFilesLimit) {
return false;
}
const attachedFiles = attachedFileIds.size === 0 ? [] : await this.driveFilesRepository.findBy({ id: In([...attachedFileIds]), userId: me.id });
for (const file of attachedFiles) {
if (!file.type.startsWith('image/')) {
return false;
}
if (file.size > 5 * 1024 * 1024) {
return false;
}
if (Math.max(file.properties.width ?? 0, file.properties.height ?? 0) > 2048) {
return false;
}
}
return true;
}
@bindThis
public async findMyRoomById(userId: MiUser['id'], roomId: MiWorldRoom['id']) {
return this.worldRoomsRepository.findOneBy({ id: roomId, userId: userId });
}
@bindThis
public async findRoomById(roomId: MiWorldRoom['id']) {
return this.worldRoomsRepository.findOne({ where: { id: roomId }, relations: { user: true } });
}
@bindThis
public async getRoomsOfUserWithPagination(userId: MiUser['id'], self: boolean, limit: number, sinceId?: MiWorldRoom['id'] | null, untilId?: MiWorldRoom['id'] | null) {
const query = this.queryService.makePaginationQuery(this.worldRoomsRepository.createQueryBuilder('room'), sinceId, untilId)
.andWhere('room.userId = :userId', { userId });
if (!self) {
query.andWhere('room.visibility = :visibility', { visibility: 'public' });
}
const rooms = await query.take(limit).getMany();
return rooms;
}
@bindThis
public async create(
me: MiUser,
body: Partial<MiWorldRoom>,
): Promise<MiWorldRoom> {
const room = await this.worldRoomsRepository.insertOne(new MiWorldRoom({
id: this.idService.gen(),
updatedAt: new Date(),
name: body.name,
description: body.description,
def: body.def,
userId: me.id,
visibility: body.visibility,
}));
return room;
}
@bindThis
public async update(
room: MiWorldRoom,
body: Partial<MiWorldRoom>,
): Promise<void> {
body.updatedAt = new Date();
return this.worldRoomsRepository.createQueryBuilder().update()
.set(body)
.where('id = :id', { id: room.id })
.returning('*')
.execute()
.then((response) => {
return response.raw[0];
});
}
@bindThis
public async delete(room: MiWorldRoom, deleter?: MiUser): Promise<void> {
await this.worldRoomsRepository.delete(room.id);
}
@bindThis
public collectReferencedDriveFileIds(roomState: MiWorldRoom['def']): Set<MiDriveFile['id']> {
const fileIds = new Set<MiDriveFile['id']>();
const installedFurnitures = roomState.installedFurnitures ?? roomState.installedObjects; // 後方互換性のため
for (const o of installedFurnitures) {
const def = driveFileReferencingOptions[o.type];
if (def == null) continue;
for (const key of def) {
const optionValue = o.options[key];
if (optionValue != null && optionValue.driveFileId != null && optionValue.driveFileId !== '') {
fileIds.add(optionValue.driveFileId);
}
}
}
return fileIds;
}
}
@@ -1,93 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository, MiWorldAvatar, WorldAvatarsRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { UserEntityService } from './UserEntityService.js';
import { DriveFileEntityService } from './DriveFileEntityService.js';
import { In } from 'typeorm';
@Injectable()
export class WorldAvatarEntityService {
constructor(
@Inject(DI.worldAvatarsRepository)
private worldAvatarsRepository: WorldAvatarsRepository,
private worldAvatarService: WorldAvatarService,
private userEntityService: UserEntityService,
private idService: IdService,
) {
}
@bindThis
public async packLite(
src: MiWorldAvatar['id'] | MiWorldAvatar,
me?: { id: MiUser['id'] } | null | undefined,
hint?: {
packedUser?: Packed<'UserLite'>
},
): Promise<Packed<'WorldAvatarLite'>> {
const meId = me ? me.id : null;
const avatar = typeof src === 'object' ? src : await this.worldAvatarsRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: avatar.id,
def: avatar.def,
});
}
@bindThis
public async packDetailed(
src: MiWorldAvatar['id'] | MiWorldAvatar,
me?: { id: MiUser['id'] } | null | undefined,
hint?: {
packedUser?: Packed<'UserLite'>
},
): Promise<Packed<'WorldAvatarDetailed'>> {
const meId = me ? me.id : null;
const avatar = typeof src === 'object' ? src : await this.worldAvatarsRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: avatar.id,
createdAt: this.idService.parse(avatar.id).date.toISOString(),
updatedAt: avatar.updatedAt.toISOString(),
name: avatar.name,
def: avatar.def,
active: avatar.active,
});
}
@bindThis
public async packLiteMany(
avatars: MiWorldAvatar[],
me?: { id: MiUser['id'] } | null | undefined,
) {
const _users = avatars.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(avatars.map(avatar => this.packLite(avatar, me, { packedUser: _userMap.get(avatar.userId) })));
}
@bindThis
public async packDetailedMany(
avatars: MiWorldAvatar[],
me?: { id: MiUser['id'] } | null | undefined,
) {
const _users = avatars.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(avatars.map(avatar => this.packDetailed(avatar, me, { packedUser: _userMap.get(avatar.userId) })));
}
}
@@ -1,97 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository, MiWorldRoom, WorldRoomsRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import type { MiUser } from '@/models/User.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { UserEntityService } from './UserEntityService.js';
import { DriveFileEntityService } from './DriveFileEntityService.js';
import { In } from 'typeorm';
@Injectable()
export class WorldRoomEntityService {
constructor(
@Inject(DI.worldRoomsRepository)
private worldRoomsRepository: WorldRoomsRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private worldRoomService: WorldRoomService,
private userEntityService: UserEntityService,
private driveFileEntityService: DriveFileEntityService,
private idService: IdService,
) {
}
@bindThis
public async packLite(
src: MiWorldRoom['id'] | MiWorldRoom,
me?: { id: MiUser['id'] } | null | undefined,
hint?: {
packedUser?: Packed<'UserLite'>
},
): Promise<Packed<'WorldRoomLite'>> {
const meId = me ? me.id : null;
const room = typeof src === 'object' ? src : await this.worldRoomsRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: room.id,
createdAt: this.idService.parse(room.id).date.toISOString(),
updatedAt: room.updatedAt.toISOString(),
userId: room.userId,
user: hint?.packedUser ?? this.userEntityService.pack(room.user ?? room.userId, me),
name: room.name,
description: room.description,
});
}
@bindThis
public async packDetailed(
src: MiWorldRoom['id'] | MiWorldRoom,
me?: { id: MiUser['id'] } | null | undefined,
hint?: {
packedUser?: Packed<'UserLite'>
},
): Promise<Packed<'WorldRoomDetailed'>> {
const meId = me ? me.id : null;
const room = typeof src === 'object' ? src : await this.worldRoomsRepository.findOneByOrFail({ id: src });
const attachedFileIds = this.worldRoomService.collectReferencedDriveFileIds(room.def);
const attachedFiles = attachedFileIds.size === 0 ? [] : await this.driveFilesRepository.findBy({ id: In([...attachedFileIds]), userId: room.userId });
return await awaitAll({
id: room.id,
createdAt: this.idService.parse(room.id).date.toISOString(),
updatedAt: room.updatedAt.toISOString(),
userId: room.userId,
user: hint?.packedUser ?? this.userEntityService.pack(room.user ?? room.userId, me),
name: room.name,
description: room.description,
def: room.def,
attachedFiles: this.driveFileEntityService.packMany(attachedFiles),
});
}
@bindThis
public async packLiteMany(
rooms: MiWorldRoom[],
me?: { id: MiUser['id'] } | null | undefined,
) {
const _users = rooms.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(rooms.map(room => this.packLite(room, me, { packedUser: _userMap.get(room.userId) })));
}
}
-2
View File
@@ -91,7 +91,5 @@ export const DI = {
bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'),
reversiGamesRepository: Symbol('reversiGamesRepository'),
noteDraftsRepository: Symbol('noteDraftsRepository'),
worldRoomsRepository: Symbol('worldRoomsRepository'),
worldAvatarsRepository: Symbol('worldAvatarsRepository'),
//#endregion
};
+22 -17
View File
@@ -132,24 +132,29 @@ function normalizeString(value: string, maxBytes: number): string {
return value.slice(0, end) + suffix;
}
/** 指定した後置文字列を含めて上限に収まる接頭辞の長さを二分探索します。 */
function findMaxPrefixLength(value: string, suffix: string, maxBytes: number): number {
let lower = 0;
let upper = value.length;
while (lower < upper) {
const middle = Math.ceil((lower + upper) / 2);
if (byteLength(value.slice(0, middle) + suffix) <= maxBytes) {
lower = middle;
} else {
upper = middle - 1;
}
function charUtf8Len(codePoint: number): number {
return codePoint < 0x80 ? 1
: codePoint < 0x800 ? 2
: codePoint < 0x10000 ? 3
: 4;
}
function charUtf16Len(codePoint: number): number {
return codePoint < 0x10000 ? 1 : 2;
}
function findMaxPrefixLength(value: string, suffix: string, maxBytes: number) {
let usedBytes = byteLength(suffix);
let prefixLength = 0;
while (prefixLength < value.length) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- similar to for-of, but for-of requires additional allocations
const cp = value.codePointAt(prefixLength)!;
const charBytes = charUtf8Len(cp);
if (usedBytes + charBytes > maxBytes) break;
usedBytes += charBytes;
prefixLength += charUtf16Len(cp);
}
// UTF-16のサロゲート対を途中で切らないよう、必要なら1文字戻します。
if (lower > 0 && lower < value.length) {
const code = value.charCodeAt(lower - 1);
if (code >= 0xd800 && code <= 0xdbff) lower--;
}
return lower;
return prefixLength;
}
/** 特殊な値に対しても、エラー判定で例外を発生させないようにします。 */
-6
View File
@@ -75,8 +75,6 @@ import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-i
import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js';
import { packedNoteDraftSchema } from '@/models/json-schema/note-draft.js';
import { packedWorldRoomDetailedSchema, packedWorldRoomLiteSchema } from '@/models/json-schema/world-room.js';
import { packedWorldAvatarDetailedSchema, packedWorldAvatarLiteSchema } from '@/models/json-schema/world-avatar.js';
export const refs = {
UserLite: packedUserLiteSchema,
@@ -149,10 +147,6 @@ export const refs = {
ChatRoom: packedChatRoomSchema,
ChatRoomInvitation: packedChatRoomInvitationSchema,
ChatRoomMembership: packedChatRoomMembershipSchema,
WorldRoomLite: packedWorldRoomLiteSchema,
WorldRoomDetailed: packedWorldRoomDetailedSchema,
WorldAvatarLite: packedWorldAvatarLiteSchema,
WorldAvatarDetailed: packedWorldAvatarDetailedSchema,
};
export type Packed<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;
@@ -84,8 +84,6 @@ import {
MiChatRoomMembership,
MiChatRoomInvitation,
MiChatApproval,
MiWorldRoom,
MiWorldAvatar,
} from './_.js';
import type { Provider } from '@nestjs/common';
import type { DataSource } from 'typeorm';
@@ -546,18 +544,6 @@ const $reversiGamesRepository: Provider = {
inject: [DI.db],
};
const $worldRoomsRepository: Provider = {
provide: DI.worldRoomsRepository,
useFactory: (db: DataSource) => db.getRepository(MiWorldRoom).extend(miRepository as MiRepository<MiWorldRoom>),
inject: [DI.db],
};
const $worldAvatarsRepository: Provider = {
provide: DI.worldAvatarsRepository,
useFactory: (db: DataSource) => db.getRepository(MiWorldAvatar).extend(miRepository as MiRepository<MiWorldAvatar>),
inject: [DI.db],
};
@Module({
imports: [],
providers: [
@@ -637,8 +623,6 @@ const $worldAvatarsRepository: Provider = {
$chatApprovalsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
$worldRoomsRepository,
$worldAvatarsRepository,
],
exports: [
$usersRepository,
@@ -717,8 +701,6 @@ const $worldAvatarsRepository: Provider = {
$chatApprovalsRepository,
$bubbleGameRecordsRepository,
$reversiGamesRepository,
$worldRoomsRepository,
$worldAvatarsRepository,
],
})
export class RepositoryModule {
@@ -1,54 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
@Entity('world_avatar')
export class MiWorldAvatar {
@PrimaryColumn(id())
public id: string;
@Index()
@Column('timestamp with time zone', {
})
public updatedAt: Date;
@Column('varchar', {
length: 256,
})
public name: string;
@Index()
@Column({
...id(),
})
public userId: MiUser['id'];
@ManyToOne(() => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
@Column('boolean', {
default: false,
})
public active: boolean;
@Column('jsonb', {
default: {},
})
public def: Record<string, any>;
constructor(data: Partial<MiWorldAvatar>) {
if (data == null) return;
for (const [k, v] of Object.entries(data)) {
(this as any)[k] = v;
}
}
}
-72
View File
@@ -1,72 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Entity, Index, JoinColumn, Column, PrimaryColumn, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
export const worldRoomVisibility = ['public', 'private'] as const;
export type WorldRoomVisibility = typeof worldRoomVisibility[number];
@Entity('world_room')
export class MiWorldRoom {
@PrimaryColumn(id())
public id: string;
@Index()
@Column('timestamp with time zone', {
})
public updatedAt: Date;
@Column('varchar', {
length: 256,
})
public name: string;
@Column('varchar', {
length: 1024,
})
public description: string;
@Index()
@Column({
...id(),
})
public userId: MiUser['id'];
@ManyToOne(() => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
@Column('integer', {
default: 0,
})
public likedCount: number;
@Column('integer', {
default: 0,
})
public accessCount: number;
@Column('varchar', {
length: 128, default: 'public',
})
public visibility: WorldRoomVisibility;
@Column('jsonb', {
default: {},
})
public def: Record<string, any>;
constructor(data: Partial<MiWorldRoom>) {
if (data == null) return;
for (const [k, v] of Object.entries(data)) {
(this as any)[k] = v;
}
}
}
+1 -7
View File
@@ -23,7 +23,7 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiChannel } from '@/models/Channel.js';
import { MiChannelFavorite } from '@/models/ChannelFavorite.js';
import { MiChannelFollowing } from '@/models/ChannelFollowing.js';
import { MiChannelMuting } from '@/models/ChannelMuting.js';
import { MiChannelMuting } from "@/models/ChannelMuting.js";
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiChatMessage } from '@/models/ChatMessage.js';
import { MiChatRoom } from '@/models/ChatRoom.js';
@@ -84,8 +84,6 @@ import { MiUserProfile } from '@/models/UserProfile.js';
import { MiUserPublickey } from '@/models/UserPublickey.js';
import { MiUserSecurityKey } from '@/models/UserSecurityKey.js';
import { MiWebhook } from '@/models/Webhook.js';
import { MiWorldRoom } from '@/models/WorldRoom.js';
import { MiWorldAvatar } from '@/models/WorldAvatar.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
export interface MiRepository<T extends ObjectLiteral> {
@@ -175,8 +173,6 @@ export {
MiChatApproval,
MiBubbleGameRecord,
MiReversiGame,
MiWorldRoom,
MiWorldAvatar,
};
export type AbuseUserReportsRepository = Repository<MiAbuseUserReport> & MiRepository<MiAbuseUserReport>;
@@ -257,5 +253,3 @@ export type ChatRoomInvitationsRepository = Repository<MiChatRoomInvitation> & M
export type ChatApprovalsRepository = Repository<MiChatApproval> & MiRepository<MiChatApproval>;
export type BubbleGameRecordsRepository = Repository<MiBubbleGameRecord> & MiRepository<MiBubbleGameRecord>;
export type ReversiGamesRepository = Repository<MiReversiGame> & MiRepository<MiReversiGame>;
export type WorldRoomsRepository = Repository<MiWorldRoom> & MiRepository<MiWorldRoom>;
export type WorldAvatarsRepository = Repository<MiWorldAvatar> & MiRepository<MiWorldAvatar>;
@@ -1,52 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const packedWorldAvatarLiteSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
def: {
type: 'object',
optional: false, nullable: false,
},
},
} as const;
export const packedWorldAvatarDetailedSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
name: {
type: 'string',
optional: false, nullable: false,
},
def: {
type: 'object',
optional: false, nullable: false,
},
active: {
type: 'boolean',
optional: false, nullable: false,
},
},
} as const;
@@ -1,95 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const packedWorldRoomLiteSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
name: {
type: 'string',
optional: false, nullable: false,
},
description: {
type: 'string',
optional: false, nullable: false,
},
},
} as const;
export const packedWorldRoomDetailedSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
updatedAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
userId: {
type: 'string',
optional: false, nullable: false,
format: 'id',
},
user: {
type: 'object',
ref: 'UserLite',
optional: false, nullable: false,
},
name: {
type: 'string',
optional: false, nullable: false,
},
description: {
type: 'string',
optional: false, nullable: false,
},
def: {
type: 'object',
optional: false, nullable: false,
},
attachedFiles: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'DriveFile',
},
},
},
} as const;
-4
View File
@@ -87,8 +87,6 @@ import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiChatApproval } from '@/models/ChatApproval.js';
import { MiSystemAccount } from '@/models/SystemAccount.js';
import { MiWorldRoom } from '@/models/WorldRoom.js';
import { MiWorldAvatar } from '@/models/WorldAvatar.js';
pg.types.setTypeParser(20, Number);
@@ -256,8 +254,6 @@ export const entities = [
MiChatApproval,
MiBubbleGameRecord,
MiReversiGame,
MiWorldRoom,
MiWorldAvatar,
...charts,
];
+1 -3
View File
@@ -6,7 +6,6 @@
import { Module } from '@nestjs/common';
import { EndpointsModule } from '@/server/api/EndpointsModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import MainStreamConnection from '@/server/api/stream/Connection.js';
import { ApiCallService } from './api/ApiCallService.js';
import { FileServerService } from './FileServerService.js';
import { HealthServerService } from './HealthServerService.js';
@@ -31,6 +30,7 @@ import { UrlPreviewService } from './web/UrlPreviewService.js';
import { ClientLoggerService } from './web/ClientLoggerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import MainStreamConnection from '@/server/api/stream/Connection.js';
import { MainChannel } from './api/stream/channels/main.js';
import { AdminChannel } from './api/stream/channels/admin.js';
import { AntennaChannel } from './api/stream/channels/antenna.js';
@@ -49,7 +49,6 @@ import { ChatUserChannel } from './api/stream/channels/chat-user.js';
import { ChatRoomChannel } from './api/stream/channels/chat-room.js';
import { ReversiChannel } from './api/stream/channels/reversi.js';
import { ReversiGameChannel } from './api/stream/channels/reversi-game.js';
import { WorldChannel } from './api/stream/channels/world.js';
import { NoteStreamingHidingService } from './api/stream/NoteStreamingHidingService.js';
import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js';
@@ -100,7 +99,6 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j
QueueStatsChannel,
ServerStatsChannel,
UserListChannel,
WorldChannel,
NoteStreamingHidingService,
OpenApiServerService,
OAuth2ProviderService,
@@ -447,14 +447,4 @@ export * as 'chat/rooms/invitations/inbox' from './endpoints/chat/rooms/invitati
export * as 'chat/rooms/invitations/outbox' from './endpoints/chat/rooms/invitations/outbox.js';
export * as 'chat/history' from './endpoints/chat/history.js';
export * as 'chat/read-all' from './endpoints/chat/read-all.js';
export * as 'world/rooms/create' from './endpoints/world/rooms/create.js';
export * as 'world/rooms/update' from './endpoints/world/rooms/update.js';
export * as 'world/rooms/delete' from './endpoints/world/rooms/delete.js';
export * as 'world/rooms/list-by-user' from './endpoints/world/rooms/list-by-user.js';
export * as 'world/rooms/show' from './endpoints/world/rooms/show.js';
export * as 'world/avatars/create' from './endpoints/world/avatars/create.js';
export * as 'world/avatars/update' from './endpoints/world/avatars/update.js';
export * as 'world/avatars/delete' from './endpoints/world/avatars/delete.js';
export * as 'world/avatars/list' from './endpoints/world/avatars/list.js';
export * as 'world/avatars/show' from './endpoints/world/avatars/show.js';
export * as 'v2/admin/emoji/list' from './endpoints/v2/admin/emoji/list.js';
@@ -12,7 +12,7 @@ export const meta = {
requireCredential: true,
requireModerator: true,
kind: 'read:admin:emoji',
kind: 'read:admin:queue',
res: {
type: 'object',
@@ -1,63 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
export const meta = {
tags: ['worldAvatar'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:worldAvatar',
limit: {
duration: ms('1day'),
max: 10,
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldAvatarDetailed',
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', maxLength: 256 },
def: { type: 'object', additionalProperties: true },
},
required: ['name', 'def'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldAvatarService: WorldAvatarService,
private worldAvatarEntityService: WorldAvatarEntityService,
) {
super(meta, paramDef, async (ps, me) => {
// TODO: validate avatar
const avatar = await this.worldAvatarService.create(me, {
name: ps.name,
def: ps.def,
});
return await this.worldAvatarEntityService.packDetailed(avatar);
});
}
}
@@ -1,50 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['worldAvatar'],
requireCredential: true,
kind: 'write:worldAvatar',
errors: {
noSuchAvatar: {
message: 'No such avatar.',
code: 'NO_SUCH_ROOM',
id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4c',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
avatarId: { type: 'string', format: 'misskey:id' },
},
required: ['avatarId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldAvatarService: WorldAvatarService,
) {
super(meta, paramDef, async (ps, me) => {
const avatar = await this.worldAvatarService.findMyAvatarById(me.id, ps.avatarId);
if (avatar == null) {
throw new ApiError(meta.errors.noSuchAvatar);
}
await this.worldAvatarService.delete(avatar, me);
});
}
}
@@ -1,62 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
import { ApiError } from '@/server/api/error.js';
import { IdService } from '@/core/IdService.js';
export const meta = {
tags: ['worldAvatar'],
requireCredential: true,
kind: 'read:worldAvatar',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldAvatarDetailed',
},
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldAvatarEntityService: WorldAvatarEntityService,
private worldAvatarService: WorldAvatarService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
const avatars = await this.worldAvatarService.getMyAvatarsWithPagination(me.id, ps.limit, sinceId, untilId);
return this.worldAvatarEntityService.packDetailedMany(avatars, me);
});
}
}
@@ -1,62 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { ApiError } from '@/server/api/error.js';
import { WorldAvatarEntityService } from '@/core/entities/WorldAvatarEntityService.js';
export const meta = {
tags: ['worldAvatar'],
requireCredential: true,
kind: 'read:worldAvatar',
res: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldAvatarDetailed',
},
errors: {
noSuchAvatar: {
message: 'No such avatar.',
code: 'NO_SUCH_ROOM',
id: '857ae02f-8759-4d20-9adb-6e95fffe4fd8',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
avatarId: { type: 'string', format: 'misskey:id' },
},
required: ['avatarId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldAvatarService: WorldAvatarService,
private worldAvatarEntityService: WorldAvatarEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const avatar = await this.worldAvatarService.findAvatarById(ps.avatarId);
if (avatar == null) {
throw new ApiError(meta.errors.noSuchAvatar);
}
if (avatar.userId !== me.id) {
throw new ApiError(meta.errors.noSuchAvatar);
}
return this.worldAvatarEntityService.packDetailed(avatar, me);
});
}
}
@@ -1,62 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldAvatarService } from '@/core/WorldAvatarService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['worldAvatar'],
requireCredential: true,
kind: 'write:worldAvatar',
res: {
},
errors: {
noSuchAvatar: {
message: 'No such avatar.',
code: 'NO_SUCH_ROOM',
id: 'fcdb0f92-bda6-47f9-bd05-343e0e020933',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
avatarId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', maxLength: 256 },
def: { type: 'object', additionalProperties: true },
active: { type: 'boolean' },
},
required: ['avatarId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldAvatarService: WorldAvatarService,
) {
super(meta, paramDef, async (ps, me) => {
const avatar = await this.worldAvatarService.findMyAvatarById(me.id, ps.avatarId);
if (avatar == null) {
throw new ApiError(meta.errors.noSuchAvatar);
}
// TODO: validate avatar
await this.worldAvatarService.update(avatar, {
name: ps.name,
def: ps.def,
active: ps.active,
});
});
}
}
@@ -1,67 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { WorldRoomEntityService } from '@/core/entities/WorldRoomEntityService.js';
export const meta = {
tags: ['worldRoom'],
requireCredential: true,
prohibitMoved: true,
kind: 'write:worldRoom',
limit: {
duration: ms('1day'),
max: 10,
},
res: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldRoomDetailed',
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', maxLength: 256 },
description: { type: 'string', maxLength: 1024 },
visibility: { type: 'string', enum: ['public', 'private'] },
def: { type: 'object', additionalProperties: true },
},
required: ['name', 'visibility', 'def'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldRoomService: WorldRoomService,
private worldRoomEntityService: WorldRoomEntityService,
) {
super(meta, paramDef, async (ps, me) => {
// TODO: validate room
const room = await this.worldRoomService.create(me, {
name: ps.name,
description: ps.description ?? '',
visibility: ps.visibility,
def: ps.def,
});
return await this.worldRoomEntityService.packDetailed(room);
});
}
}
@@ -1,50 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['worldRoom'],
requireCredential: true,
kind: 'write:worldRoom',
errors: {
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: 'd4e3753d-97bf-4a19-ab8e-21080fbc0f4c',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roomId: { type: 'string', format: 'misskey:id' },
},
required: ['roomId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldRoomService: WorldRoomService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.worldRoomService.findMyRoomById(me.id, ps.roomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
await this.worldRoomService.delete(room, me);
});
}
}
@@ -1,63 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { WorldRoomEntityService } from '@/core/entities/WorldRoomEntityService.js';
import { ApiError } from '@/server/api/error.js';
import { IdService } from '@/core/IdService.js';
export const meta = {
tags: ['worldRoom'],
requireCredential: true,
kind: 'read:worldRoom',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldRoomLite',
},
},
errors: {
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
},
required: ['userId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldRoomEntityService: WorldRoomEntityService,
private worldRoomService: WorldRoomService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
const sinceId = ps.sinceId ?? (ps.sinceDate ? this.idService.gen(ps.sinceDate!) : null);
const rooms = await this.worldRoomService.getRoomsOfUserWithPagination(ps.userId, ps.userId === me.id, ps.limit, sinceId, untilId);
return this.worldRoomEntityService.packLiteMany(rooms, me);
});
}
}
@@ -1,62 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { ApiError } from '@/server/api/error.js';
import { WorldRoomEntityService } from '@/core/entities/WorldRoomEntityService.js';
export const meta = {
tags: ['worldRoom'],
requireCredential: true,
kind: 'read:worldRoom',
res: {
type: 'object',
optional: false, nullable: false,
ref: 'WorldRoomDetailed',
},
errors: {
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: '857ae02f-8759-4d20-9adb-6e95fffe4fd8',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roomId: { type: 'string', format: 'misskey:id' },
},
required: ['roomId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldRoomService: WorldRoomService,
private worldRoomEntityService: WorldRoomEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.worldRoomService.findRoomById(ps.roomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
if (room.userId !== me.id && room.visibility === 'private') {
throw new ApiError(meta.errors.noSuchRoom);
}
return this.worldRoomEntityService.packDetailed(room, me);
});
}
}
@@ -1,64 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['worldRoom'],
requireCredential: true,
kind: 'write:worldRoom',
res: {
},
errors: {
noSuchRoom: {
message: 'No such room.',
code: 'NO_SUCH_ROOM',
id: 'fcdb0f92-bda6-47f9-bd05-343e0e020933',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roomId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', maxLength: 256 },
description: { type: 'string', maxLength: 1024 },
visibility: { type: 'string', enum: ['public', 'private'] },
def: { type: 'object', additionalProperties: true },
},
required: ['roomId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private worldRoomService: WorldRoomService,
) {
super(meta, paramDef, async (ps, me) => {
const room = await this.worldRoomService.findMyRoomById(me.id, ps.roomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);
}
// TODO: validate room
await this.worldRoomService.update(room, {
name: ps.name,
description: ps.description,
visibility: ps.visibility,
def: ps.def,
});
});
}
}
@@ -35,7 +35,6 @@ import { ChatUserChannel } from '@/server/api/stream/channels/chat-user.js';
import { ChatRoomChannel } from '@/server/api/stream/channels/chat-room.js';
import { ReversiChannel } from '@/server/api/stream/channels/reversi.js';
import { ReversiGameChannel } from '@/server/api/stream/channels/reversi-game.js';
import { WorldChannel } from '@/server/api/stream/channels/world.js';
import type { ChannelRequest } from './channel.js';
import type { ChannelConstructor } from './channel.js';
import type Channel from './channel.js';
@@ -339,7 +338,6 @@ export default class Connection {
case 'chatRoom': return ChatRoomChannel;
case 'reversi': return ReversiChannel;
case 'reversiGame': return ReversiGameChannel;
case 'world': return WorldChannel;
default:
throw new Error(`no such channel: ${name}`);
@@ -1,113 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import { WorldRoomService } from '@/core/WorldRoomService.js';
import { WorldMultiplayService } from '@/core/WorldMultiplayService.js';
import Channel, { type ChannelRequest } from '../channel.js';
@Injectable({ scope: Scope.TRANSIENT })
export class WorldChannel extends Channel {
public readonly chName = 'world';
public static shouldShare = false;
public static requireCredential = true as const;
public static kind = 'read:world';
private roomId: string;
private spaceKey: string;
private intervalId: NodeJS.Timeout;
private isEntered = false;
constructor(
@Inject(REQUEST)
request: ChannelRequest,
private worldRoomService: WorldRoomService,
private worldMultiplayService: WorldMultiplayService,
) {
super(request);
}
@bindThis
public async init(params: JsonObject): Promise<boolean> {
if (typeof params.spaceKey !== 'string') return false;
if (!this.user) return false;
this.spaceKey = params.spaceKey;
try {
await this.enter();
} catch (err) {
return false;
}
this.subscriber.on(`worldStream:${this.spaceKey}`, this.onEvent);
return true;
}
@bindThis
private async enter() {
if (this.isEntered) return;
await this.worldMultiplayService.enter(this.user!.id, this.spaceKey);
this.isEntered = true;
this.send('entered', {
playerProfiles: await this.worldMultiplayService.getPlayerProfiles(this.spaceKey, this.user!.id),
});
this.intervalId = setInterval(async () => {
const states = await this.worldMultiplayService.getPlayerStatesAndHeatbeat(this.user!.id, this.spaceKey);
delete states[this.user!.id];
this.send('sync', states);
}, 100);
}
@bindThis
private async onEvent(data: GlobalEvents['world']['payload']) {
switch (data.type) {
case 'enter': {
if (data.body.user.id === this.user!.id) return; // 自分の入室は無視
this.send('playerEntered', {
id: data.body.user.id,
profile: this.worldMultiplayService.packPlayerProfile(data.body.user, data.body.avatar),
});
break;
}
case 'left': {
if (data.body.userId === this.user!.id) return; // 自分の退室は無視
this.send('playerLeft', {
id: data.body.userId,
});
break;
}
}
}
@bindThis
public onMessage(type: string, body: any) {
switch (type) {
case 'update':
if (this.spaceKey != null && this.isEntered) {
this.worldMultiplayService.updatePlayerState(this.user!.id, this.spaceKey, body);
}
break;
}
}
@bindThis
public dispose() {
this.subscriber.off(`worldStream:${this.spaceKey}`, this.onEvent);
clearInterval(this.intervalId);
this.worldMultiplayService.left(this.user!.id, this.spaceKey);
}
}
@@ -14,7 +14,7 @@ import { mockDeep } from 'vitest-mock-extended';
import { GlobalModule } from '@/GlobalModule.js';
import { FileInfo, FileInfoService } from '@/core/FileInfoService.js';
//import { DI } from '@/di-symbols.js';
import { AiService } from '@/core/AiService.js';
import { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type { TestingModule } from '@nestjs/testing';
@@ -41,13 +41,13 @@ describe('FileInfoService', () => {
GlobalModule,
],
providers: [
AiService,
SensitiveMediaDetectionService,
LoggerService,
FileInfoService,
],
})
.useMocker((token) => {
//if (token === AiService) {
//if (token === SensitiveMediaDetectionService) {
// return { };
//}
if (typeof token === 'function') {
@@ -7,7 +7,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest';
import type { MiMeta } from '@/models/_.js';
import type { HttpRequestService } from '@/core/HttpRequestService.js';
import type { LoggerService } from '@/core/LoggerService.js';
import { AiService, type Prediction } from '@/core/AiService.js';
import { SensitiveMediaDetectionService, type Prediction } from '@/core/SensitiveMediaDetectionService.js';
const sendMock = vi.fn();
@@ -18,13 +18,13 @@ const DEFAULT_META = {
sensitiveMediaDetectionMaxImagesPerRequest: 4,
};
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): AiService {
function makeService(metaOverrides: Partial<typeof DEFAULT_META> = {}): SensitiveMediaDetectionService {
const meta = { ...DEFAULT_META, ...metaOverrides } as unknown as MiMeta;
const httpRequestService = { send: sendMock } as unknown as HttpRequestService;
const loggerService = {
getLogger: () => ({ warn: () => {}, error: () => {}, info: () => {} }),
} as unknown as LoggerService;
return new AiService(meta, httpRequestService, loggerService);
return new SensitiveMediaDetectionService(meta, httpRequestService, loggerService);
}
function neutral(): Prediction[] {
@@ -42,7 +42,7 @@ function okResponse(results: unknown[]) {
const buf = (s: string) => Buffer.from(s);
describe('AiService', () => {
describe('SensitiveMediaDetectionService', () => {
beforeEach(() => {
sendMock.mockReset();
});
@@ -11,7 +11,7 @@ import { describe, expect, test, beforeAll, afterAll, afterEach } from 'vitest';
import sharp from 'sharp';
import { DataSource, type Repository } from 'typeorm';
import { initTestDb, randomString } from '../../utils.js';
import type { AiService } from '@/core/AiService.js';
import type { SensitiveMediaDetectionService } from '@/core/SensitiveMediaDetectionService.js';
import { DownloadService } from '@/core/DownloadService.js';
import { FileInfoService } from '@/core/FileInfoService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
@@ -147,11 +147,11 @@ describe('FileServerService', () => {
driveFilesRepository = db.getRepository(MiDriveFile);
const loggerService = new LoggerService();
const aiService = {
const sensitiveMediaDetectionService = {
detectSensitive: async () => null,
detectSensitiveMany: async (sources: Buffer[]) => sources.map(() => null),
} as unknown as AiService;
const fileInfoService = new FileInfoService(aiService, loggerService);
} as unknown as SensitiveMediaDetectionService;
const fileInfoService = new FileInfoService(sensitiveMediaDetectionService, loggerService);
const httpRequestService = new HttpRequestService(config);
const downloadService = new DownloadService(config, httpRequestService, loggerService);
const imageProcessingService = new ImageProcessingService();
@@ -1,5 +0,0 @@
# frontend用Misskey Worldエンジン
エンジンはWeb Worker内で動作し、ほぼすべてのMisskey Webの機能は使えないため、意図しないそれらへの参照/依存が原理的に発生しないように別パッケージとする
ただしヘッドレス動作することは(今のところ)意図していない
@@ -1,28 +0,0 @@
import tsParser from '@typescript-eslint/parser';
import sharedConfig from '../shared/eslint.config.js';
// eslint-disable-next-line import/no-default-export
export default [
...sharedConfig,
{
ignores: [
'**/node_modules',
'built',
'coverage',
'jest.config.ts',
'test',
'test-d',
],
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {
parserOptions: {
parser: tsParser,
project: ['./tsconfig.json'],
sourceType: 'module',
tsconfigRootDir: import.meta.dirname,
},
},
},
];
@@ -1,36 +0,0 @@
{
"type": "module",
"name": "frontend-misskey-world-engine",
"private": true,
"scripts": {
"eslint": "eslint './**/*.{js,jsx,ts,tsx}'",
"test": "vitest run --config vitest.config.ts",
"typecheck": "tsgo --noEmit",
"lint": "pnpm typecheck && pnpm eslint"
},
"devDependencies": {
"@types/seedrandom": "3.0.8",
"@typescript-eslint/eslint-plugin": "8.59.2",
"@typescript-eslint/parser": "8.59.2",
"esbuild": "0.28.0",
"execa": "9.6.1",
"nodemon": "3.1.14",
"throttle-debounce": "5.0.2",
"@types/tinycolor2": "1.4.6",
"vitest": "4.1.10"
},
"files": [
"built"
],
"dependencies": {
"@babylonjs/core": "9.19.0",
"@babylonjs/inspector": "9.19.0",
"@babylonjs/loaders": "9.19.0",
"@babylonjs/materials": "9.19.0",
"@types/throttle-debounce": "5.0.2",
"eventemitter3": "5.0.4",
"seedrandom": "3.0.5",
"tinycolor2": "1.6.0",
"hls.js": "1.6.16"
}
}
@@ -1,344 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
type InstancesBatch = Parameters<BABYLON.OutlineRenderer['render']>[1];
const TRIANGLE_FILL_MODES = new Set<number>([
BABYLON.Material.TriangleFillMode,
BABYLON.Material.TriangleStripDrawMode,
BABYLON.Material.TriangleFanDrawMode,
]);
export type CelShadingOutlineRenderer = Pick<BABYLON.OutlineRenderer, 'enabled' | 'zOffset' | 'zOffsetUnits' | 'render'>;
export type CelShadingOptions = {
enabled: boolean;
color: BABYLON.Color3;
width: number;
};
export type CelShadingRendererDependencies = {
outlineRenderer: CelShadingOutlineRenderer;
};
type QueuedSubMesh = {
mesh: BABYLON.Mesh;
subMesh: BABYLON.SubMesh;
batch: InstancesBatch;
options: CelShadingOptions;
};
type RenderingGroupHook = {
group: BABYLON.RenderingGroup;
previous: (() => void) | undefined;
callback: () => void;
};
type EngineState = {
depthTest: boolean;
depthMask: boolean;
depthFunc: BABYLON.Nullable<number>;
cull: BABYLON.Nullable<boolean>;
cullFace: BABYLON.Nullable<number>;
frontFace: BABYLON.Nullable<number>;
zOffset: number;
zOffsetUnits: number;
alphaMode: number;
colorWrite: boolean;
stencilBuffer: boolean;
stencilMaterial: BABYLON.IStencilState | undefined;
cullBackFaces: BABYLON.Nullable<boolean>;
};
export class CelShadingRenderer implements BABYLON.ISceneComponent {
public readonly name = 'CelShadingRenderer';
public readonly scene: BABYLON.Scene;
private readonly outlineRenderer: CelShadingOutlineRenderer;
private readonly outlineRenderPassId: number;
private readonly defaultOptions: CelShadingOptions;
private readonly meshOptions = new WeakMap<BABYLON.Mesh, Partial<CelShadingOptions>>();
private readonly renderingGroupHooks = new Map<BABYLON.RenderingGroup, RenderingGroupHook>();
private readonly recordedOutlineSubMeshes = new Set<BABYLON.SubMesh>();
private readonly queuedSubMeshes: QueuedSubMesh[] = [];
private readonly queuedSubMeshSet = new Set<BABYLON.SubMesh>();
private readonly worldScale = new BABYLON.Vector3();
private currentRenderingGroupId: number | null = null;
private disposed = false;
private readonly beforeRenderingGroupObserver: BABYLON.Observer<BABYLON.RenderingGroupInfo>;
private readonly afterRenderingGroupObserver: BABYLON.Observer<BABYLON.RenderingGroupInfo>;
private readonly beforeParticlesRenderingObserver: BABYLON.Observer<BABYLON.Scene>;
private readonly beforeDrawPhaseObserver: BABYLON.Observer<BABYLON.Scene>;
public constructor(scene: BABYLON.Scene, options: CelShadingOptions, dependencies?: CelShadingRendererDependencies) {
this.scene = scene;
this.defaultOptions = {
enabled: options.enabled,
color: options.color.clone(),
width: options.width,
};
this.outlineRenderer = dependencies?.outlineRenderer ?? this.scene.getOutlineRenderer();
this.outlineRenderer.enabled = false;
this.outlineRenderer.zOffset = 0;
this.outlineRenderer.zOffsetUnits = 0;
this.outlineRenderPassId = this.scene.getEngine().createRenderPassId('Cel Shading Outline');
this.register();
this.beforeRenderingGroupObserver = this.scene.onBeforeRenderingGroupObservable.add(this.onBeforeRenderingGroup);
this.afterRenderingGroupObserver = this.scene.onAfterRenderingGroupObservable.add(this.onAfterRenderingGroup);
this.beforeParticlesRenderingObserver = this.scene.onBeforeParticlesRenderingObservable.add(this.onBeforeParticlesRendering);
this.beforeDrawPhaseObserver = this.scene.onBeforeDrawPhaseObservable.add(this.updateSnapshotOutlineUniforms);
}
public setMeshOptions(mesh: BABYLON.Mesh, options: Partial<CelShadingOptions>): void {
this.meshOptions.set(mesh, {
...this.meshOptions.get(mesh),
...options,
});
}
public clearMeshOptions(mesh: BABYLON.Mesh): void {
this.meshOptions.delete(mesh);
}
public excludeMesh(mesh: BABYLON.Mesh): void {
this.setMeshOptions(mesh, { enabled: false });
}
public includeMesh(mesh: BABYLON.Mesh): void {
this.setMeshOptions(mesh, { enabled: true });
}
public register(): void {
this.scene._afterRenderingMeshStage.registerStep(0, this, this.collectSubMesh);
}
public rebuild(): void {
}
public dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.scene.onBeforeRenderingGroupObservable.remove(this.beforeRenderingGroupObserver);
this.scene.onAfterRenderingGroupObservable.remove(this.afterRenderingGroupObserver);
this.scene.onBeforeParticlesRenderingObservable.remove(this.beforeParticlesRenderingObserver);
this.scene.onBeforeDrawPhaseObservable.remove(this.beforeDrawPhaseObserver);
this.removeStageSteps();
for (const hook of this.renderingGroupHooks.values()) {
if (hook.group.onBeforeTransparentRendering === hook.callback) {
hook.group.onBeforeTransparentRendering = hook.previous!;
}
}
this.renderingGroupHooks.clear();
this.recordedOutlineSubMeshes.clear();
this.clearQueue();
this.scene.getEngine().releaseRenderPassId(this.outlineRenderPassId);
}
private readonly onBeforeRenderingGroup = (info: BABYLON.RenderingGroupInfo): void => {
this.currentRenderingGroupId = info.renderingGroupId;
this.clearQueue();
this.installRenderingGroupHook(info.renderingGroupId, info.renderingManager.getRenderingGroup(info.renderingGroupId));
};
private readonly onAfterRenderingGroup = (): void => {
this.clearQueue();
this.currentRenderingGroupId = null;
};
private readonly onBeforeParticlesRendering = (): void => {
if (this.currentRenderingGroupId !== null) this.flush(this.currentRenderingGroupId);
};
private readonly updateSnapshotOutlineUniforms = (): void => {
const engine = this.scene.getEngine();
if (!engine.snapshotRendering || engine.snapshotRenderingMode !== BABYLON.Constants.SNAPSHOTRENDERING_FAST) {
this.recordedOutlineSubMeshes.clear();
return;
}
// FAST snapshot replay skips OutlineRenderer.render(), so its standalone uniforms must be updated directly.
const viewProjection = this.scene.getTransformMatrix();
for (const subMesh of this.recordedOutlineSubMeshes) {
const drawWrapper = subMesh._getDrawWrapper(this.outlineRenderPassId);
const effect = drawWrapper?.effect;
const dataBuffer = (drawWrapper?.drawContext as BABYLON.WebGPUDrawContext | undefined)?.buffers['LeftOver'];
const uniformBuffer = (effect?._pipelineContext as BABYLON.WebGPUPipelineContext | undefined)?.uniformBuffer;
if (effect == null || dataBuffer == null || uniformBuffer == null || !uniformBuffer.setDataBuffer(dataBuffer)) continue;
const material = subMesh.getMaterial();
if (material == null) continue;
const renderingMesh = subMesh.getRenderingMesh();
if (renderingMesh.isDisposed()) continue;
const ownerMesh = subMesh.getMesh();
const effectiveMesh = ownerMesh._internalAbstractMeshDataInfo._actAsRegularMesh ? ownerMesh : renderingMesh;
const options = this.resolveMeshOptions(renderingMesh);
const localWidth = options.enabled && Number.isFinite(options.width) && options.width > 0
? this.getLocalWidth(renderingMesh, options.width)
: 0;
effect.setMatrix('viewProjection', viewProjection);
effect.setMatrix('world', effectiveMesh.computeWorldMatrix());
effect.setFloat('offset', localWidth);
effect.setColor4('color', options.color, material.alpha);
uniformBuffer.update();
}
};
private collectSubMesh(mesh: BABYLON.Mesh, subMesh: BABYLON.SubMesh, batch: InstancesBatch): void {
if (this.currentRenderingGroupId === null) return;
if (!this.isMainRenderPass()) return;
if (mesh.renderingGroupId !== this.currentRenderingGroupId) return;
const material = subMesh.getMaterial();
if (material == null || material.needAlphaBlendingForMesh(mesh)) return;
if (!TRIANGLE_FILL_MODES.has(material.fillMode)) return;
if (!mesh.isVerticesDataPresent(BABYLON.VertexBuffer.NormalKind)) return;
const options = this.resolveMeshOptions(mesh);
if (!options.enabled || !Number.isFinite(options.width) || options.width <= 0) return;
if (this.queuedSubMeshSet.has(subMesh)) return;
this.queuedSubMeshSet.add(subMesh);
this.queuedSubMeshes.push({ mesh, subMesh, batch, options });
}
private resolveMeshOptions(mesh: BABYLON.Mesh): CelShadingOptions {
return {
...this.defaultOptions,
...this.meshOptions.get(mesh),
};
}
private isMainRenderPass(): boolean {
const camera = this.scene.activeCamera;
const mainRenderPassId = camera?.outputRenderTarget?.renderPassId ?? camera?.renderPassId ?? BABYLON.Constants.RENDERPASS_MAIN;
return this.scene.getEngine().currentRenderPassId === mainRenderPassId;
}
private installRenderingGroupHook(renderingGroupId: number, group: BABYLON.RenderingGroup): void {
if (this.renderingGroupHooks.has(group)) return;
const previous = group.onBeforeTransparentRendering;
const callback = (): void => {
previous?.();
this.flush(renderingGroupId);
};
group.onBeforeTransparentRendering = callback;
this.renderingGroupHooks.set(group, { group, previous, callback });
}
private flush(renderingGroupId: number): void {
if (this.currentRenderingGroupId !== renderingGroupId || this.queuedSubMeshes.length === 0) return;
const engine = this.scene.getEngine();
const previousEngineState = this.captureEngineState(engine);
try {
this.prepareEngineForOutline(engine);
for (const entry of this.queuedSubMeshes) {
const localWidth = this.getLocalWidth(entry.mesh, entry.options.width);
if (localWidth <= 0) continue;
this.setInvertedHullCulling(entry);
const previousWidth = entry.mesh.outlineWidth;
const previousColor = entry.mesh.outlineColor;
try {
entry.mesh.outlineWidth = localWidth;
entry.mesh.outlineColor = entry.options.color;
this.outlineRenderer.render(entry.subMesh, entry.batch, false, this.outlineRenderPassId);
this.recordedOutlineSubMeshes.add(entry.subMesh);
} finally {
entry.mesh.outlineWidth = previousWidth;
entry.mesh.outlineColor = previousColor;
}
}
} finally {
this.restoreEngineState(engine, previousEngineState);
this.clearQueue();
}
}
private captureEngineState(engine: BABYLON.AbstractEngine): EngineState {
const depth = engine.depthCullingState;
return {
depthTest: depth.depthTest,
depthMask: depth.depthMask,
depthFunc: depth.depthFunc,
cull: depth.cull,
cullFace: depth.cullFace,
frontFace: depth.frontFace,
zOffset: depth.zOffset,
zOffsetUnits: depth.zOffsetUnits,
alphaMode: engine.getAlphaMode(),
colorWrite: engine.getColorWrite(),
stencilBuffer: engine.getStencilBuffer(),
stencilMaterial: engine.stencilStateComposer.stencilMaterial,
cullBackFaces: engine.cullBackFaces,
};
}
private prepareEngineForOutline(engine: BABYLON.AbstractEngine): void {
engine.cullBackFaces = null;
engine.setAlphaMode(BABYLON.Constants.ALPHA_DISABLE);
engine.setColorWrite(true);
engine.setStencilBuffer(false);
const depth = engine.depthCullingState;
depth.depthTest = true;
depth.depthMask = true;
depth.depthFunc = engine.useReverseDepthBuffer ? BABYLON.Constants.GEQUAL : BABYLON.Constants.LEQUAL;
depth.zOffset = 0;
depth.zOffsetUnits = 0;
}
private restoreEngineState(engine: BABYLON.AbstractEngine, state: EngineState): void {
engine.cullBackFaces = state.cullBackFaces;
engine.setAlphaMode(state.alphaMode);
engine.setColorWrite(state.colorWrite);
engine.setStencilBuffer(state.stencilBuffer);
engine.stencilStateComposer.stencilMaterial = state.stencilMaterial;
const depth = engine.depthCullingState;
depth.depthTest = state.depthTest;
depth.depthMask = state.depthMask;
depth.depthFunc = state.depthFunc;
depth.cull = state.cull;
depth.cullFace = state.cullFace;
depth.frontFace = state.frontFace;
depth.zOffset = state.zOffset;
depth.zOffsetUnits = state.zOffsetUnits;
}
private setInvertedHullCulling(entry: QueuedSubMesh): void {
const material = entry.subMesh.getMaterial()!;
let orientation = material._getEffectiveOrientation(entry.mesh);
if (entry.mesh._getWorldMatrixDeterminant() < 0) {
orientation = orientation === BABYLON.Material.ClockWiseSideOrientation
? BABYLON.Material.CounterClockWiseSideOrientation
: BABYLON.Material.ClockWiseSideOrientation;
}
const reverseSide = orientation === BABYLON.Material.ClockWiseSideOrientation;
this.scene.getEngine().setState(true, 0, true, reverseSide, false, undefined, 0);
}
private getLocalWidth(mesh: BABYLON.Mesh, worldWidth: number): number {
if (!mesh.getWorldMatrix().decompose(this.worldScale)) return 0;
const maxWorldScale = Math.max(Math.abs(this.worldScale.x), Math.abs(this.worldScale.y), Math.abs(this.worldScale.z));
return maxWorldScale > BABYLON.Epsilon ? worldWidth / maxWorldScale : 0;
}
private clearQueue(): void {
this.queuedSubMeshes.length = 0;
this.queuedSubMeshSet.clear();
}
private removeStageSteps(): void {
const stage = this.scene._afterRenderingMeshStage;
for (let i = stage.length - 1; i >= 0; i--) {
if (stage[i].component === this) stage.splice(i, 1);
}
}
}
@@ -1,129 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import EventEmitter from 'eventemitter3';
const IN_WEB_WORKER = typeof window === 'undefined';
export type EngineBaseEvents = {
'loadingProgress': (ctx: { progress: number }) => void;
'contextlost': (ctx: { reason: string; message: string; }) => void;
};
export abstract class EngineBase<EVs extends EngineBaseEvents> extends EventEmitter<{
'ev': (ctx: { type: keyof EVs; ctx: Parameters<EVs[keyof EVs]>[0] }) => void;
}> {
declare _eventTypes?: EVs;
protected babylonEngine: BABYLON.WebGPUEngine;
public scene: BABYLON.Scene;
abstract sr: BABYLON.SnapshotRenderingHelper;
abstract lightContainer: BABYLON.ClusteredLightContainer;
abstract getEnvMap(): BABYLON.CubeTexture | null;
protected fps: number | null = null;
protected disposed = false;
public inputs: EventEmitter<{
'click': (event: { x: number; y: number; }) => void;
'keydown': (event: { code: string; shiftKey: boolean; }) => void;
'keyup': (event: { code: string; shiftKey: boolean; }) => void;
'wheel': (event: { deltaY: number; }) => void;
'zoom': (event: { delta: number; }) => void;
'pointer': (event: { x: number; y: number; }) => void;
}> = new EventEmitter();
constructor(options: {
babylonEngine: BABYLON.WebGPUEngine;
fps: number | null;
}) {
super();
this.fps = options.fps;
this.babylonEngine = options.babylonEngine;
// doNotHandleContextLostがtrueだとそもそも呼ばれない
//babylonEngine.onContextLostObservable.add(() => {
// os.alert({
// type: 'error',
// title: i18n.ts.somethingHappened,
// text: i18n.ts._miWorld.crushed_description,
// });
//});
this.babylonEngine._device.lost.then((info) => { // TODO: babylonEngineの内部プロパティに依存しない方法をforumで聞く
this.ev('contextlost', { reason: info.reason, message: info.message }); // transferableじゃないデータが含まれている可能性も考慮してinfoそのままは送らない
});
this.scene = new BABYLON.Scene(this.babylonEngine);
}
private currentRafId: number | null = null;
protected startRenderLoop() {
if (this.fps == null) {
this.babylonEngine.runRenderLoop(() => {
this.scene.render();
});
} else {
let then = 0;
const interval = 1000 / this.fps;
const renderLoop = (timeStamp: number) => {
if (this.disposed) return;
// workerで実行される可能性がある
this.currentRafId = requestAnimationFrame(renderLoop);
const delta = timeStamp - then;
if (delta <= interval) return;
then = timeStamp - (delta % interval);
this.babylonEngine.beginFrame();
this.scene.render();
this.babylonEngine.endFrame();
};
// workerで実行される可能性がある
this.currentRafId = requestAnimationFrame(renderLoop);
}
}
public pauseRender() { // TODO: srと同じく参照カウント方式にした方が便利そう
this.babylonEngine.stopRenderLoop();
if (this.currentRafId != null) {
// workerで実行される可能性がある
cancelAnimationFrame(this.currentRafId);
this.currentRafId = null;
}
}
public resumeRender() {
this.startRenderLoop();
}
public abstract init(): Promise<void>;
protected ev<K extends keyof EVs>(type: K, ctx: Parameters<EVs[K]>[0]) {
this.emit('ev', { type, ctx });
}
public async takeScreenshot() {
return await BABYLON.Tools.CreateScreenshotAsync(this.babylonEngine, this.scene.activeCamera!, { precision: 1 });
}
public abstract resize(): void;
public destroy() {
this.babylonEngine.stopRenderLoop();
if (this.currentRafId != null) {
// workerで実行される可能性がある
cancelAnimationFrame(this.currentRafId);
this.currentRafId = null;
}
this.babylonEngine.dispose();
this.scene.dispose();
this.disposed = true;
}
}
@@ -1,176 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { randomRange, Timer } from './utility.js';
import type { EngineBase } from './EngineBase.js';
export class Firework {
private engine: EngineBase<any>;
private timer: Timer = new Timer();
private texturePatterns = [
{ path: '/client-assets/world/other/firework/flare-glow-1.png', color: new BABYLON.Color3(0.0, 1.0, 0.0) },
{ path: '/client-assets/world/other/firework/flare-glow-2.png', color: new BABYLON.Color3(0.0, 1.0, 1.0) },
{ path: '/client-assets/world/other/firework/flare-glow-3.png', color: new BABYLON.Color3(0.0, 0.0, 1.0) },
{ path: '/client-assets/world/other/firework/flare-glow-4.png', color: new BABYLON.Color3(1.0, 0.0, 1.0) },
{ path: '/client-assets/world/other/firework/flare-glow-5.png', color: new BABYLON.Color3(1.0, 0.0, 0.0) },
{ path: '/client-assets/world/other/firework/flare-glow-6.png', color: new BABYLON.Color3(1.0, 0.5, 0.0) },
{ path: '/client-assets/world/other/firework/flare-glow-7.png', color: new BABYLON.Color3(1.0, 1.0, 0.0) },
{ path: '/client-assets/world/other/firework/flare-glow-8.png', color: new BABYLON.Color3(0.5, 1.0, 0.0) },
];
private RENDERING_GROUP: number;
constructor(engine: EngineBase<any>, renderingGroup: number) {
this.engine = engine;
this.RENDERING_GROUP = renderingGroup;
}
public launch(options: {
position: [number, number, number];
}) {
this.engine.sr.disableSnapshotRendering();
const texturePattern = this.texturePatterns[Math.floor(Math.random() * this.texturePatterns.length)];
const texture = new BABYLON.Texture(texturePattern.path, this.engine.scene);
const textureScaleFactor = 3;
//const emitter = new BABYLON.TransformNode('emitter', this.engine.scene);
const emitter = BABYLON.MeshBuilder.CreateBox('emitter', { size: cm(10) }, this.engine.scene);
emitter.isVisible = false;
emitter.position = new BABYLON.Vector3(options.position[0], options.position[1], options.position[2]);
const ps = new BABYLON.ParticleSystem('', 32, this.engine.scene);
ps.renderingGroupId = this.RENDERING_GROUP;
ps.particleTexture = texture;
ps.emitter = emitter;
ps.minEmitPower = cm(100);
ps.maxEmitPower = cm(500);
ps.minLifeTime = 0.5;
ps.maxLifeTime = 1;
ps.minSize = cm(3) * textureScaleFactor;
ps.maxSize = cm(30) * textureScaleFactor;
ps.addDragGradient(0, 0.1);
ps.addDragGradient(1, 0.8);
//ps.direction1 = new BABYLON.Vector3(0, 1, 0);
//ps.direction2 = new BABYLON.Vector3(0, 1, 0);
ps.emitRate = 30;
ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;
//ps.color1 = new BABYLON.Color4(1, 1, 1, 0.3);
//ps.color2 = new BABYLON.Color4(1, 1, 1, 0.2);
ps.colorDead = new BABYLON.Color4(1, 1, 1, 0);
ps.start();
this.engine.sr.fixParticleSystem(ps);
const launchAnim = new BABYLON.Animation(
'',
'position',
60,
BABYLON.Animation.ANIMATIONTYPE_VECTOR3,
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT,
);
launchAnim.setKeys([
{ frame: 0, value: new BABYLON.Vector3(options.position[0], options.position[1], options.position[2]) },
{ frame: 60, value: new BABYLON.Vector3(options.position[0], options.position[1] + cm(randomRange(2000, 6000)), options.position[2]) },
]);
emitter.animations.push(launchAnim);
const animating = Promise.withResolvers<void>();
this.engine.scene.beginAnimation(emitter, 0, 60, false, 1, () => { animating.resolve(); });
animating.promise.then(() => {
ps.stop();
this.explode({
position: [emitter.position.x, emitter.position.y, emitter.position.z],
texture,
textureScaleFactor,
color: texturePattern.color,
callback: () => { // explode途中でSRの状態を切り替えるとパーティクルが消える現象があるため、explodeが終了してから片づける
this.engine.sr.disableSnapshotRendering();
ps.dispose();
emitter.dispose();
this.engine.sr.enableSnapshotRendering();
},
});
});
this.engine.sr.enableSnapshotRendering();
}
public explode(options: {
position: [number, number, number];
texture: BABYLON.Texture;
textureScaleFactor: number;
color: BABYLON.Color3;
callback: () => void;
}) {
this.engine.sr.disableSnapshotRendering();
const pos = new BABYLON.Vector3(options.position[0], options.position[1], options.position[2]);
const light = new BABYLON.PointLight('', pos, this.engine.scene, true);
light.range = cm(10000);
light.radius = cm(50);
light.intensity = 100000 * WORLD_SCALE * WORLD_SCALE;
light.diffuse = options.color;
this.engine.lightContainer.addLight(light);
const lightAnim = new BABYLON.Animation(
'',
'intensity',
120,
BABYLON.Animation.ANIMATIONTYPE_FLOAT,
BABYLON.Animation.ANIMATIONLOOPMODE_CONSTANT,
);
lightAnim.setKeys([
{ frame: 0, value: 100000 * WORLD_SCALE * WORLD_SCALE },
{ frame: 120, value: 0 },
]);
light.animations.push(lightAnim);
this.engine.scene.beginAnimation(light, 0, 120, false, 1);
const ps = new BABYLON.ParticleSystem('', 128, this.engine.scene);
ps.renderingGroupId = this.RENDERING_GROUP;
ps.particleTexture = options.texture;
ps.emitter = pos;
ps.minEmitPower = cm(3700);
ps.maxEmitPower = cm(4000);
ps.minLifeTime = 0.8;
ps.maxLifeTime = 1;
ps.addDragGradient(0, 0.1);
ps.addDragGradient(1, 0.8);
ps.gravity = new BABYLON.Vector3(0, -50, 0).scale(WORLD_SCALE);
ps.minSize = cm(50) * options.textureScaleFactor;
ps.maxSize = cm(50) * options.textureScaleFactor;
const sphereEmitter = ps.createSphereEmitter(cm(1));
//ps.direction1 = new BABYLON.Vector3(0, 1, 0);
//ps.direction2 = new BABYLON.Vector3(0, 1, 0);
ps.manualEmitCount = 100;
ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_MULTIPLYADD;
ps.addColorGradient(0.0, new BABYLON.Color4(1, 1, 1, 1));
ps.addColorGradient(0.7, new BABYLON.Color4(1, 1, 1, 1));
ps.addColorGradient(1.0, new BABYLON.Color4(1, 1, 1, 0));
ps.start();
this.engine.sr.fixParticleSystem(ps);
this.timer.setTimeout(() => {
this.engine.sr.disableSnapshotRendering();
ps.dispose();
light.dispose();
this.engine.lightContainer.removeLight(light);
this.engine.scene.removeLight(light); // lc使用時はsceneには追加してないはずだが、これがないとクラッシュする babylonのバグ?
this.engine.sr.enableSnapshotRendering();
options.callback();
}, 2000);
this.engine.sr.enableSnapshotRendering();
}
public dispose() {
this.timer.dispose();
}
}
@@ -1,229 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { EngineBase } from './EngineBase.js';
import { PlayerContainer, type PlayerProfile, type PlayerState } from './PlayerContainer.js';
import { FreeCameraManualInput } from './utility.js';
const IN_WEB_WORKER = typeof window === 'undefined';
export type MultiplayEngineBaseEvents = {
'loadingProgress': (ctx: { progress: number }) => void;
'contextlost': (ctx: { reason: string; message: string; }) => void;
'changeSittingState': (ctx: { isSitting: boolean }) => void;
};
export abstract class MultiplayEngineBase<EVs extends MultiplayEngineBaseEvents> extends EngineBase<EVs> {
protected playerProfiles: Record<string, PlayerProfile> = {};
protected playerContainers: PlayerContainer[] = [];
protected showUsernameOnAvatar: boolean;
protected show2dAvatarOnAvatar: boolean;
public camera: BABYLON.FreeCamera;
public fixedCamera: BABYLON.FreeCamera;
protected cameraHeight = cm(130);
protected fov: number;
protected isGodMode = false;
private _isSitting = false;
get isSitting() {
return this._isSitting;
}
set isSitting(v) {
this._isSitting = v;
this.ev('changeSittingState', { isSitting: v });
}
constructor(options: {
babylonEngine: BABYLON.WebGPUEngine;
fps: number | null;
showUsernameOnAvatar: boolean;
show2dAvatarOnAvatar: boolean;
useVirtualJoystick: boolean;
fov: number;
fastMovement: boolean;
}) {
super({
babylonEngine: options.babylonEngine,
fps: options.fps,
});
this.showUsernameOnAvatar = options.showUsernameOnAvatar;
this.show2dAvatarOnAvatar = options.show2dAvatarOnAvatar;
this.fov = options.fov;
this.camera = new BABYLON.FreeCamera('', new BABYLON.Vector3(0, this.cameraHeight, cm(0)), this.scene);
this.camera.minZ = cm(1);
this.camera.maxZ = cm(1000);
this.camera.fov = this.fov;
this.camera.ellipsoid = new BABYLON.Vector3(cm(15), cm(65), cm(15));
if (!this.isGodMode) {
this.camera.checkCollisions = true;
this.camera.applyGravity = true;
this.camera.needMoveForGravity = true;
}
this.camera.inputs.clear();
if (options.useVirtualJoystick) {
this.camera.inputs.add(new FreeCameraManualInput(this.scene, {
moveSensitivity: options.fastMovement ? 0.02 * WORLD_SCALE : 0.015 * WORLD_SCALE,
rotationSensitivity: 0.0007,
isGodMode: this.isGodMode,
}));
this.camera.inertia = 0.75;
} else {
this.camera.inputs.add(new FreeCameraManualInput(this.scene, {
moveSensitivity: options.fastMovement ? 0.003 * WORLD_SCALE : 0.002 * WORLD_SCALE,
rotationSensitivity: 0.0003,
isGodMode: this.isGodMode,
}));
}
this.scene.activeCamera = this.camera;
this.fixedCamera = new BABYLON.FreeCamera('', new BABYLON.Vector3(0, cm(130), cm(0)), this.scene);
this.fixedCamera.minZ = cm(1);
this.fixedCamera.maxZ = cm(1000);
this.fixedCamera.inputs.clear();
this.fixedCamera.inputs.add(new FreeCameraManualInput(this.scene, {
moveSensitivity: 0.002 * WORLD_SCALE,
rotationSensitivity: 0.0003,
}));
}
public sit() {
this.isSitting = true;
this.sr.disableSnapshotRendering();
this.fixedCamera.parent = null;
this.fixedCamera.position = new BABYLON.Vector3(this.camera.position.x, cm(70), this.camera.position.z);
this.fixedCamera.rotation = new BABYLON.Vector3(this.camera.rotation.x, this.camera.rotation.y, this.camera.rotation.z);
this.fixedCamera.maxZ = this.camera.maxZ;
this.scene.activeCamera = this.fixedCamera;
this.sr.enableSnapshotRendering();
}
public lyingDown() {
this.isSitting = true;
this.sr.disableSnapshotRendering();
this.fixedCamera.parent = null;
this.fixedCamera.position = new BABYLON.Vector3(this.camera.position.x, cm(20), this.camera.position.z);
this.fixedCamera.rotation = new BABYLON.Vector3(-(Math.PI / 2) + 0.001, this.camera.rotation.y, this.camera.rotation.z);
this.fixedCamera.maxZ = this.camera.maxZ;
this.scene.activeCamera = this.fixedCamera;
this.sr.enableSnapshotRendering();
}
public standUp() {
this.isSitting = false;
this.scene.activeCamera = this.camera;
this.fixedCamera.parent = null;
}
public updatePlayerProfiles(profiles: Record<string, PlayerProfile>) {
this.playerProfiles = profiles;
for (const playerContainer of this.playerContainers) {
if (this.playerProfiles[playerContainer.id] == null) {
this.sr.disableSnapshotRendering();
playerContainer.destroy();
this.sr.enableSnapshotRendering();
}
}
this.playerContainers = this.playerContainers.filter(p => this.playerProfiles[p.id] != null);
}
public updatePlayerStates(states: Record<string, PlayerState>) {
for (const [k, v] of Object.entries(this.playerProfiles)) {
const playerContainer = this.playerContainers.find(p => p.id === k);
if (playerContainer == null) {
const p = new PlayerContainer({
id: k,
profile: v,
state: states[k],
scene: this.scene,
sr: this.sr,
showUsername: this.showUsernameOnAvatar,
show2dAvatar: this.show2dAvatarOnAvatar,
});
// TODO: loadFurnitureのものとある程度共通化
p.registerMeshes = (meshes) => {
for (const mesh of meshes) {
mesh.receiveShadows = false;
mesh.metadata = { isPlayer: true, playerId: k };
//if (mesh.material) (mesh.material as BABYLON.PBRMaterial).ambientColor = new BABYLON.Color3(0.2, 0.2, 0.2);
if (mesh.material) {
if (mesh.material instanceof BABYLON.MultiMaterial) {
for (const subMat of mesh.material.subMaterials) {
if ((subMat as BABYLON.PBRMaterial).subSurface.isRefractionEnabled) {
(subMat as BABYLON.PBRMaterial).subSurface.isRefractionEnabled = false; // 有効にするとドローコールが激増する
(subMat as BABYLON.PBRMaterial).transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND;
(subMat as BABYLON.PBRMaterial).alpha = 0.5;
(subMat as BABYLON.PBRMaterial).metallic = 1;
}
(subMat as BABYLON.PBRMaterial).reflectionTexture = this.getEnvMap();
if ((subMat as BABYLON.PBRMaterial).metadata == null) (subMat as BABYLON.PBRMaterial).metadata = {};
(subMat as BABYLON.PBRMaterial).metadata.useEnvMap = true;
(subMat as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
(subMat as BABYLON.PBRMaterial).anisotropy.isEnabled = false; // なんかきれいにレンダリングされないため
}
} else {
if ((mesh.material as BABYLON.PBRMaterial).subSurface.isRefractionEnabled) {
(mesh.material as BABYLON.PBRMaterial).subSurface.isRefractionEnabled = false; // 有効にするとドローコールが激増する
(mesh.material as BABYLON.PBRMaterial).transparencyMode = BABYLON.PBRMaterial.PBRMATERIAL_ALPHABLEND;
(mesh.material as BABYLON.PBRMaterial).alpha = 0.5;
(mesh.material as BABYLON.PBRMaterial).metallic = 1;
}
(mesh.material as BABYLON.PBRMaterial).reflectionTexture = this.getEnvMap();
if ((mesh.material as BABYLON.PBRMaterial).metadata == null) (mesh.material as BABYLON.PBRMaterial).metadata = {};
(mesh.material as BABYLON.PBRMaterial).metadata.useEnvMap = true;
(mesh.material as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
(mesh.material as BABYLON.PBRMaterial).anisotropy.isEnabled = false; // なんかきれいにレンダリングされないため
}
}
if (!this.scene.meshes.includes(mesh)) this.scene.addMesh(mesh);
}
};
p.loadAvatar().then(() => {
this.sr.disableSnapshotRendering();
this.sr.enableSnapshotRendering();
});
this.playerContainers.push(p);
} else {
if (states[k] != null) {
playerContainer.applyState(states[k]);
}
}
}
}
public clearPlayers() {
this.sr.disableSnapshotRendering();
for (const playerContainer of this.playerContainers) {
playerContainer.destroy();
}
this.sr.enableSnapshotRendering();
this.playerContainers = [];
}
public updateAvatarDisplayOptions(options: { showUsername: boolean; show2dAvatar: boolean }) {
this.showUsernameOnAvatar = options.showUsername;
this.show2dAvatarOnAvatar = options.show2dAvatar;
this.sr.disableSnapshotRendering();
for (const playerContainer of this.playerContainers) {
playerContainer.updateUserInfoDisplayOptions(options);
}
this.sr.enableSnapshotRendering();
}
public destroy() {
for (const playerContainer of this.playerContainers) {
playerContainer.destroy();
}
super.destroy();
}
}
@@ -1,335 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { AccessoryContainer } from './avatars/AccessoryContainer.js';
import { getAccessoryDef } from './avatars/accessory-defs.js';
import { createTextMesh, Timer } from './utility.js';
import type { WorldAvatar } from 'misskey-world/src/types.js';
export type PlayerProfile = {
user: {
name: string;
username: string;
avatarUrl: string;
} | null;
avatar: WorldAvatar;
};
export type PlayerState = {
position: [number, number, number],
rotation: [number, number, number],
sit?: string; // id
};
const DEFAULT_FACE_PARTS_EYES = {
'_none_': null,
'a': '/client-assets/world/avatars/eyes-a.png',
'b': '/client-assets/world/avatars/eyes-b.png',
'c': '/client-assets/world/avatars/eyes-c.png',
'd': '/client-assets/world/avatars/eyes-d.png',
'e': '/client-assets/world/avatars/eyes-e.png',
'f': '/client-assets/world/avatars/eyes-f.png',
'g': '/client-assets/world/avatars/eyes-g.png',
};
const DEFAULT_FACE_PARTS_MOUTH = {
'_none_': null,
'a': '/client-assets/world/avatars/mouth-a.png',
'b': '/client-assets/world/avatars/mouth-b.png',
'c': '/client-assets/world/avatars/mouth-c.png',
'd': '/client-assets/world/avatars/mouth-d.png',
'e': '/client-assets/world/avatars/mouth-e.png',
'f': '/client-assets/world/avatars/mouth-f.png',
'g': '/client-assets/world/avatars/mouth-g.png',
'h': '/client-assets/world/avatars/mouth-h.png',
'i': '/client-assets/world/avatars/mouth-i.png',
};
let usernameLabelMaterial: BABYLON.StandardMaterial | null = null;
export class PlayerContainer {
public id: string;
private profile: PlayerProfile;
public root: BABYLON.TransformNode;
private subRootContainerForAnim: BABYLON.TransformNode;
private subRoot: BABYLON.TransformNode;
private modelRoot: BABYLON.TransformNode | null = null;
private sr: BABYLON.SnapshotRenderingHelper;
private scene: BABYLON.Scene;
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
private animationObserver: BABYLON.Observer<BABYLON.Scene> | null = null;
private accessoryContainers: AccessoryContainer[] = [];
private timer: Timer = new Timer();
private showUsername: boolean;
private show2dAvatar: boolean;
private usernameLabelMesh: BABYLON.Mesh | null = null;
private twodAvatarMesh: BABYLON.Mesh | null = null;
constructor(params: { id: string; profile: PlayerProfile; state: PlayerState | null; sr: BABYLON.SnapshotRenderingHelper; scene: BABYLON.Scene; showUsername: boolean; show2dAvatar: boolean; }) {
this.id = params.id;
this.profile = params.profile;
this.sr = params.sr;
this.scene = params.scene;
this.root = new BABYLON.TransformNode(`player:${this.id}`, params.scene);
this.root.rotationQuaternion = null;
this.subRootContainerForAnim = new BABYLON.TransformNode(`player:${this.id}:subRootContainerForAnim`, params.scene);
this.subRootContainerForAnim.parent = this.root;
this.subRoot = new BABYLON.TransformNode(`player:${this.id}:subRoot`, params.scene);
this.subRoot.parent = this.subRootContainerForAnim;
this.showUsername = params.showUsername;
this.show2dAvatar = params.show2dAvatar;
this.applyInfoMesh();
if (params.state) this.applyState(params.state, true);
}
public async loadAvatar() {
const filePath = '/client-assets/world/avatars/default.glb';
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
// babylonによって自動で追加される右手系変換用ノード
const modelRootMesh = loaderResult.meshes[0] as BABYLON.Mesh;
// meshじゃなくtransform nodeにしてパフォーマンス向上
this.modelRoot = new BABYLON.TransformNode('__root__', this.scene);
this.modelRoot.parent = this.subRoot;
this.modelRoot.scaling.x = -1;
this.modelRoot.scaling = this.modelRoot.scaling.scale(WORLD_SCALE);// cmをmに
for (const m of modelRootMesh.getChildren()) {
if (m.parent === modelRootMesh) {
m.parent = this.modelRoot;
}
}
modelRootMesh.dispose();
const eyesBlinkTexture = new BABYLON.Texture('/client-assets/world/avatars/eyes-blink.png', this.scene, false, false);
eyesBlinkTexture.hasAlpha = true;
let eyesTex: BABYLON.Texture | null = null;
if (this.profile.avatar.eyes.type in DEFAULT_FACE_PARTS_EYES) {
const eyesTexPath = DEFAULT_FACE_PARTS_EYES[this.profile.avatar.eyes.type];
if (eyesTexPath) {
eyesTex = new BABYLON.Texture(eyesTexPath, this.scene, false, false);
eyesTex.hasAlpha = true;
}
}
let mouthTex: BABYLON.Texture | null = null;
if (this.profile.avatar.mouth.type in DEFAULT_FACE_PARTS_MOUTH) {
const mouthTexPath = DEFAULT_FACE_PARTS_MOUTH[this.profile.avatar.mouth.type];
if (mouthTexPath) {
mouthTex = new BABYLON.Texture(mouthTexPath, this.scene, false, false);
mouthTex.hasAlpha = true;
}
}
for (const mesh of this.modelRoot.getChildMeshes()) {
if (mesh.name.includes('__BODY__')) {
mesh.material.albedoColor = new BABYLON.Color3(this.profile.avatar.body.color[0], this.profile.avatar.body.color[1], this.profile.avatar.body.color[2]);
}
if (mesh.name.includes('__EYES__')) {
const mat = new BABYLON.PBRMaterial('', this.scene);
mat.albedoColor = new BABYLON.Color3(this.profile.avatar.eyes.color[0], this.profile.avatar.eyes.color[1], this.profile.avatar.eyes.color[2]);
mat.albedoTexture = eyesTex;
mat.roughness = 1;
mat.metallic = 0;
mesh.material = mat;
// TODO: SRを無効にせずに表現する方法を考える
const blink = () => {
if (mesh.isDisposed()) return;
this.sr.disableSnapshotRendering();
mat.albedoTexture = eyesBlinkTexture;
this.sr.enableSnapshotRendering();
this.timer.setTimeout(() => {
this.sr.disableSnapshotRendering();
mat.albedoTexture = eyesTex;
this.sr.enableSnapshotRendering();
this.timer.setTimeout(() => {
blink();
}, Math.random() * 10000);
}, 100);
};
this.timer.setTimeout(() => {
blink();
}, Math.random() * 10000);
}
if (mesh.name.includes('__MOUTH__')) {
if (mouthTex != null) {
const mat = new BABYLON.PBRMaterial('', this.scene);
mat.albedoColor = new BABYLON.Color3(this.profile.avatar.mouth.color[0], this.profile.avatar.mouth.color[1], this.profile.avatar.mouth.color[2]);
mat.albedoTexture = mouthTex;
mat.roughness = 1;
mat.metallic = 0;
mesh.material = mat;
} else {
mesh.isVisible = false;
}
}
}
this.registerMeshes(this.modelRoot.getChildMeshes());
this.accessoryContainers = await Promise.all(this.profile.avatar.accessories.map(ac => this.loadAccessory({
type: ac.type,
id: ac.id,
position: new BABYLON.Vector3(0, cm(19), 0),
rotation: new BABYLON.Vector3(0, 0, 0),
options: ac.options,
})));
const anim = new BABYLON.Animation('', 'position.y', 60, BABYLON.Animation.ANIMATIONTYPE_FLOAT, BABYLON.Animation.ANIMATIONLOOPMODE_CYCLE);
anim.setKeys([
{ frame: 0, value: cm(0) },
{ frame: 30, value: cm(-2) },
{ frame: 60, value: cm(0) },
{ frame: 90, value: cm(2) },
{ frame: 120, value: cm(0) },
]);
this.subRootContainerForAnim.animations = [anim];
this.animationObserver = this.scene.onAfterAnimationsObservable.add(() => {
this.sr.updateMesh(this.subRootContainerForAnim.getChildMeshes(), false);
});
this.scene.beginAnimation(this.subRootContainerForAnim, 0, 120, true);
}
private async loadAccessory(args: {
type: string;
id: string;
position: BABYLON.Vector3;
rotation: BABYLON.Vector3;
options: Record<string, unknown>;
}) {
const def = getAccessoryDef(args.type);
const container = new AccessoryContainer({
id: args.id,
type: args.type,
position: args.position.clone(),
rotation: args.rotation.clone(),
options: args.options,
sr: this.sr,
getIsSrReady: () => true,
lightContainer: this.lightContainer,
graphicsQuality: this.graphicsQuality,
scene: this.scene,
});
container.registerMeshes = (meshes) => {
this.registerMeshes(meshes);
};
await container.load();
container.root.parent = this.subRoot;
return container;
}
private applyInfoMesh() {
if (this.showUsername ) {
if (this.usernameLabelMesh == null) {
if (usernameLabelMaterial == null) {
const usernameLabelTex = new BABYLON.Texture('/client-assets/world/chars-black.png', this.scene, false, false);
usernameLabelMaterial = new BABYLON.StandardMaterial('usernameLabelMaterial', this.scene);
usernameLabelMaterial.roughness = 1;
usernameLabelMaterial.diffuseColor = new BABYLON.Color3(1, 1, 1);
usernameLabelMaterial.diffuseTexture = usernameLabelTex;
usernameLabelMaterial.emissiveColor = new BABYLON.Color3(1, 1, 1);
usernameLabelMaterial.emissiveTexture = usernameLabelTex;
usernameLabelMaterial.disableLighting = true;
}
this.usernameLabelMesh = createTextMesh(this.profile.user?.username ?? '(anonymous)', {
size: cm(5),
material: usernameLabelMaterial,
});
this.usernameLabelMesh.parent = this.subRoot;
this.usernameLabelMesh.position.y = cm(40);
this.usernameLabelMesh.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;
this.scene.addMesh(this.usernameLabelMesh);
}
} else {
if (this.usernameLabelMesh != null) {
this.usernameLabelMesh.dispose();
this.scene.removeMesh(this.usernameLabelMesh);
this.usernameLabelMesh = null;
}
}
if (this.show2dAvatar && this.profile.user?.avatarUrl != null) {
if (this.twodAvatarMesh == null) {
const twodAvatarTex = new BABYLON.Texture(this.profile.user.avatarUrl, this.scene, false, true);
const twodAvatarMat = new BABYLON.StandardMaterial('twodAvatarMat', this.scene);
twodAvatarMat.roughness = 1;
twodAvatarMat.diffuseColor = new BABYLON.Color3(0.5, 0.5, 0.5);
twodAvatarMat.diffuseTexture = twodAvatarTex;
twodAvatarMat.emissiveColor = new BABYLON.Color3(0.5, 0.5, 0.5);
twodAvatarMat.emissiveTexture = twodAvatarTex;
twodAvatarMat.disableLighting = true;
twodAvatarMat.backFaceCulling = false;
this.twodAvatarMesh = BABYLON.MeshBuilder.CreatePlane('twodAvatar', { size: cm(10) }, this.scene);
this.twodAvatarMesh.material = twodAvatarMat;
this.twodAvatarMesh.parent = this.subRoot;
this.twodAvatarMesh.position.y = cm(40) + cm(7.5);
this.twodAvatarMesh.billboardMode = BABYLON.Mesh.BILLBOARDMODE_ALL;
this.scene.addMesh(this.twodAvatarMesh);
}
} else {
if (this.twodAvatarMesh != null) {
this.twodAvatarMesh.dispose(false, true);
this.scene.removeMesh(this.twodAvatarMesh);
this.twodAvatarMesh = null;
}
}
}
public updateUserInfoDisplayOptions(options: { showUsername: boolean; show2dAvatar: boolean; }) {
this.showUsername = options.showUsername;
this.show2dAvatar = options.show2dAvatar;
this.applyInfoMesh();
}
public applyState(state: PlayerState, forInit = false) {
this.root.position.set(...state.position);
this.subRoot.rotation.set(...state.rotation);
if (!forInit) {
const meshes = this.root.getChildMeshes();
if (meshes.length > 0) this.sr.updateMesh(meshes);
}
}
public destroy() {
this.timer.dispose();
if (this.animationObserver != null) {
this.scene.onAfterAnimationsObservable.remove(this.animationObserver);
}
for (const ac of this.accessoryContainers) {
ac.destroy();
}
this.accessoryContainers = [];
if (this.usernameLabelMesh != null) {
this.usernameLabelMesh.dispose();
this.scene.removeMesh(this.usernameLabelMesh);
this.usernameLabelMesh = null;
}
if (this.twodAvatarMesh != null) {
this.twodAvatarMesh.dispose(false, true);
this.scene.removeMesh(this.twodAvatarMesh);
this.twodAvatarMesh = null;
}
this.root.dispose();
}
}
@@ -1,208 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { ArcRotateCameraManualInput, getMeshesBoundingBox, GRAPHICS_QUALITY } from './utility.js';
import { PlayerContainer, type PlayerProfile } from './PlayerContainer.js';
import { EngineBase } from './EngineBase.js';
import { deepClone } from './clone.js';
import type { WorldAvatar } from 'misskey-world/src/types.js';
export class AvatarPreviewEngine extends EngineBase<{ // PlayerPreviewEngineに改名した方がいいかもしれない
'loadingProgress': (ctx: { progress: number }) => void;
'contextlost': (ctx: { reason: string; message: string; }) => void;
}> {
private sr: BABYLON.SnapshotRenderingHelper;
private shadowGenerator: BABYLON.ShadowGenerator;
private camera: BABYLON.ArcRotateCamera;
private avatarOptions: WorldAvatar | null = null;
private playerContainer: PlayerContainer | null = null;
private envMapIndoor: BABYLON.CubeTexture;
private roomLight: BABYLON.SpotLight;
private pipeline: BABYLON.DefaultRenderingPipeline;
private graphicsQuality: number;
private profile: PlayerProfile;
constructor(profile: PlayerProfile, options: {
babylonEngine: BABYLON.WebGPUEngine;
graphicsQuality: number;
fps: number | null;
}) {
super({
babylonEngine: options.babylonEngine,
fps: options.fps,
});
registerBuiltInLoaders();
this.graphicsQuality = options.graphicsQuality;
this.profile = deepClone(profile);
this.scene.autoClear = false;
this.scene.skipPointerMovePicking = true;
this.scene.skipFrustumClipping = true; // snapshot renderingでは全てのメッシュがアクティブになっている必要があるため
this.scene.clearColor = new BABYLON.Color4(0.01, 0.01, 0.01, 1);
this.sr = new BABYLON.SnapshotRenderingHelper(this.scene);
this.camera = new BABYLON.ArcRotateCamera('camera', Math.PI / 2, Math.PI / 2.5, cm(300), new BABYLON.Vector3(0, cm(90), 0), this.scene);
this.camera.minZ = cm(1);
this.camera.maxZ = cm(100000);
this.camera.fov = 0.5;
this.camera.lowerRadiusLimit = cm(50);
this.camera.upperRadiusLimit = cm(1000);
this.camera.inputs.clear();
this.camera.inputs.add(new ArcRotateCameraManualInput(this.scene, {
rotationSensitivity: 0.0005,
}));
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.scene);
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(500), cm(500), cm(500));
this.envMapIndoor.level = 0.6;
this.roomLight = new BABYLON.SpotLight('roomLight', new BABYLON.Vector3(cm(50), cm(249), cm(50)), new BABYLON.Vector3(0, -1, 0), 16, 8, this.scene);
this.roomLight.diffuse = new BABYLON.Color3(1.0, 0.9, 0.8);
this.roomLight.shadowMinZ = cm(10);
this.roomLight.shadowMaxZ = cm(500);
this.roomLight.radius = cm(30);
this.roomLight.intensity = 15 * WORLD_SCALE * WORLD_SCALE;
this.shadowGenerator = new BABYLON.ShadowGenerator(2048, this.roomLight);
this.shadowGenerator.forceBackFacesOnly = true;
this.shadowGenerator.bias = 0.0001;
this.shadowGenerator.usePercentageCloserFiltering = true;
this.shadowGenerator.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
this.shadowGenerator.getShadowMap().refreshRate = 60;
const gl = new BABYLON.GlowLayer('glow', this.scene, {
blurKernelSize: 64,
});
gl.intensity = 0.5;
this.scene.setRenderingAutoClearDepthStencil(gl.renderingGroupId, false);
this.sr.updateMeshesForEffectLayer(gl);
this.pipeline = new BABYLON.DefaultRenderingPipeline('default', true, this.scene);
this.pipeline.samples = 4;
if (this.graphicsQuality >= GRAPHICS_QUALITY.HIGH) {
this.pipeline.bloomEnabled = true;
this.pipeline.bloomThreshold = 0.95;
this.pipeline.bloomWeight = 0.1;
this.pipeline.bloomKernel = 256;
this.pipeline.bloomScale = 2;
}
this.pipeline.sharpenEnabled = true;
this.pipeline.sharpen.edgeAmount = 0.5;
}
public async init() {
this.startRenderLoop();
await this.scene.whenReadyAsync();
this.sr.enableSnapshotRendering();
this.inputs.on('wheel', (ev) => {
this.camera.fov += ev.deltaY * 0.0005;
this.camera.fov = Math.max(0.25, Math.min(0.5, this.camera.fov));
});
this.inputs.on('zoom', (ev) => {
this.camera.fov += -ev.delta * 0.0015;
this.camera.fov = Math.max(0.25, Math.min(0.5, this.camera.fov));
});
this.inputs.on('pointer', (ev) => {
(this.camera.inputs.attached.manual as ArcRotateCameraManualInput).setRotationVector({ x: ev.x, y: ev.y });
});
await this.load();
}
private async load() {
this.sr.disableSnapshotRendering();
this.playerContainer = new PlayerContainer({
id: '',
profile: this.profile,
state: {
position: [0, 0, 0],
rotation: [0, 0, 0],
},
sr: this.sr,
scene: this.scene,
showUsername: false,
show2dAvatar: false,
});
this.playerContainer.registerMeshes = (meshes) => {
for (const mesh of meshes) {
mesh.receiveShadows = true;
this.shadowGenerator.addShadowCaster(mesh);
if (mesh.material) {
if (mesh.material instanceof BABYLON.MultiMaterial) {
for (const subMat of mesh.material.subMaterials) {
(subMat as BABYLON.PBRMaterial).reflectionTexture = this.envMapIndoor;
(subMat as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
(subMat as BABYLON.PBRMaterial).anisotropy.isEnabled = false; // なんかきれいにレンダリングされないため
}
} else {
(mesh.material as BABYLON.PBRMaterial).reflectionTexture = this.envMapIndoor;
(mesh.material as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
(mesh.material as BABYLON.PBRMaterial).anisotropy.isEnabled = false; // なんかきれいにレンダリングされないため
}
}
if (!this.scene.meshes.includes(mesh)) this.scene.addMesh(mesh);
}
};
await this.playerContainer.loadAvatar();
const boundingInfo = getMeshesBoundingBox(this.playerContainer.root.getChildMeshes().filter(m => m.isEnabled() && m.isVisible), true);
this.camera.setTarget(new BABYLON.Vector3(0, boundingInfo.centerWorld.y, 0));
// zoom to fit
const size = boundingInfo.extendSize;
const distance = Math.max(size.x, size.y, size.z) * 2;
this.camera.radius = distance * 5;
this.sr.enableSnapshotRendering();
}
public clearPlayer() {
this.sr.disableSnapshotRendering();
if (this.playerContainer != null) {
this.playerContainer.destroy();
this.playerContainer = null;
}
this.sr.enableSnapshotRendering();
}
public async updateAvatar(value: WorldAvatar) {
this.profile.avatar = value;
this.clearPlayer();
await this.load();
}
public resize() {
// 一旦snapshot renderingを無効にしておかないとエラーが出る(babylonのバグ?)
// ~~...が、一旦無効にしたらしたで複数のマテリアルがそれぞれ入れ替わる(?)という謎の現象が発生するためコメントアウトしとく(エラー出てもレンダリングが止まったりするわけでもないし)~~
// ↑追記: engine.resizeした後に一瞬待つことで回避できることが判明
this.sr.disableSnapshotRendering();
this.babylonEngine.resize(true);
// workerで実行される可能性がある
setTimeout(() => {
this.sr.enableSnapshotRendering();
}, 1);
}
public destroy() {
super.destroy();
this.playerContainer?.destroy();
}
}
@@ -1,29 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { AvatarPreviewEngine } from './avatarPreviewEngine.js';
import { registerBabylonRuntime } from './babylonRuntime.js';
import type { PlayerProfile } from './PlayerContainer.js';
registerBabylonRuntime();
export async function createAvatarPreviewEngine(params: {
canvas: HTMLCanvasElement; options: { graphicsQuality: number; resolution: number; fps: number | null }; profile: PlayerProfile;
}) {
const babylonEngine = new BABYLON.WebGPUEngine(params.canvas, { doNotHandleContextLost: true, powerPreference: 'low-power', antialias: true });
babylonEngine.compatibilityMode = false;
babylonEngine.enableOfflineSupport = false;
await babylonEngine.initAsync();
if (params.options.resolution === 2) babylonEngine.setHardwareScalingLevel(0.5);
if (params.options.resolution === 0.5) babylonEngine.setHardwareScalingLevel(2);
const engine = new AvatarPreviewEngine(params.profile, {
babylonEngine: babylonEngine,
...params.options,
});
return engine;
}
@@ -1,110 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { AvatarPreviewEngine } from './avatarPreviewEngine.js';
import { registerBabylonRuntime } from './babylonRuntime.js';
import type { PlayerProfile } from './PlayerContainer.js';
registerBabylonRuntime();
let engine: AvatarPreviewEngine | null = null;
let canvas: OffscreenCanvas | null = null;
// TODO: 他のWorkerと実装を共通化
onmessage = async (event) => {
//console.log('Worker received message:', event.data);
switch (event.data?.type) {
case 'init': {
const profile = event.data.profile as PlayerProfile;
canvas = event.data.canvas as OffscreenCanvas;
const babylonEngine = new BABYLON.WebGPUEngine(canvas, { doNotHandleContextLost: true, powerPreference: 'low-power', antialias: true });
babylonEngine.compatibilityMode = false;
babylonEngine.enableOfflineSupport = false;
await babylonEngine.initAsync();
if (event.data.options.resolution === 2) babylonEngine.setHardwareScalingLevel(0.5);
if (event.data.options.resolution === 0.5) babylonEngine.setHardwareScalingLevel(2);
engine = new AvatarPreviewEngine(profile, {
babylonEngine: babylonEngine,
...event.data.options,
});
engine.on('ev', ({ type, ctx }) => {
self.postMessage({ type: 'ev', ev: { type, ctx } });
});
await engine.init();
self.postMessage({ type: 'inited' });
break;
}
case 'resize': {
canvas.width = event.data.width;
canvas.height = event.data.height;
if (engine != null) engine.resize();
break;
}
case 'input:keydown': {
if (engine == null) break;
engine.inputs.emit('keydown', event.data.ev);
break;
}
case 'input:keyup': {
if (engine == null) break;
engine.inputs.emit('keyup', event.data.ev);
break;
}
case 'input:click': {
if (engine == null) break;
engine.inputs.emit('click', event.data.ev);
break;
}
case 'input:wheel': {
if (engine == null) break;
engine.inputs.emit('wheel', event.data.ev);
break;
}
case 'input:zoom': {
if (engine == null) break;
engine.inputs.emit('zoom', event.data.ev);
break;
}
case 'input:pointer': {
if (engine == null) break;
engine.inputs.emit('pointer', event.data.ev);
break;
}
case 'call': {
if (engine == null) {
console.error('Failed to call: Engine is not initialized yet!!!');
break;
}
const res = engine[event.data.fn](...(event.data.args ?? []));
if (event.data.needReturnValue) {
if (res instanceof Promise) {
res.then((r) => {
self.postMessage({ type: 'return', id: event.data.id, value: r });
});
} else {
self.postMessage({ type: 'return', id: event.data.id, value: res });
}
}
break;
}
case 'set': {
if (engine == null) {
console.error('Failed to set: Engine is not initialized yet!!!');
break;
}
engine[event.data.key] = event.data.value;
break;
}
default: {
console.warn('Unrecognized message type:', event.data?.type);
}
}
};
@@ -1,142 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { camelToKebab, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { ModelExplorer, scaleMorph, Timer } from '../utility.js';
import { convertRawOptions, type ConvertedOptions, type RawOptions } from '../mono.js';
import { getAccessoryDef } from './accessory-defs.js';
import type { AvatarAccessoryInstance } from './accessory.js';
export class AccessoryContainer {
public id: string;
public type: string;
private options: ConvertedOptions;
public root: BABYLON.TransformNode;
private subRoot: BABYLON.TransformNode | null = null;
public instance: AvatarAccessoryInstance | null = null;
public model: ModelExplorer | null = null;
private scene: BABYLON.Scene;
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
private sr: BABYLON.SnapshotRenderingHelper;
private getIsSrReady: () => boolean;
private lightContainer: BABYLON.ClusteredLightContainer;
private graphicsQuality: number;
private timer: Timer = new Timer();
constructor(args: {
id: string;
type: string;
options: RawOptions;
position: BABYLON.Vector3;
rotation: BABYLON.Vector3;
sr: BABYLON.SnapshotRenderingHelper;
getIsSrReady: () => boolean;
lightContainer: BABYLON.ClusteredLightContainer;
scene: BABYLON.Scene;
graphicsQuality: number;
}) {
this.id = args.id;
this.type = args.type;
const def = getAccessoryDef(this.type);
this.options = convertRawOptions(def.options.schema, args.options, { files: [] });
this.sr = args.sr;
this.getIsSrReady = args.getIsSrReady;
this.lightContainer = args.lightContainer;
this.scene = args.scene;
this.graphicsQuality = args.graphicsQuality;
this.root = new BABYLON.TransformNode(`accessory_${args.id}_${args.type}`, this.scene);
this.root.position = args.position;
this.root.rotation = args.rotation;
}
public async load() {
const def = getAccessoryDef(this.type);
const filePath = def.path != null ? `/client-assets/world/objects/${def.path(this.options)}.glb` : `/client-assets/world/objects/${camelToKebab(this.type)}/${camelToKebab(this.type)}.glb`;
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
// babylonによって自動で追加される右手系変換用ノード
const subRootMesh = loaderResult.meshes[0] as BABYLON.Mesh;
// meshじゃなくtransform nodeにしてパフォーマンス向上
this.subRoot = new BABYLON.TransformNode('__root__', this.scene);
this.subRoot.parent = this.root;
this.subRoot.scaling.x = -1;
this.subRoot.scaling = this.subRoot.scaling.scale(WORLD_SCALE);// cmをmに
for (const m of subRootMesh.getChildren()) {
if (m.parent === subRootMesh) {
m.parent = this.subRoot;
}
}
subRootMesh.dispose();
this.registerMeshes(this.subRoot.getChildMeshes());
this.model = new ModelExplorer(this.subRoot);
this.instance = await def.createInstance({
scene: this.scene,
sr: {
updateMesh: (mesh) => {
if (!this.getIsSrReady()) return;
this.sr.updateMesh(mesh);
},
reset: () => {
if (!this.getIsSrReady()) return;
this.sr.disableSnapshotRendering();
this.sr.enableSnapshotRendering();
},
fixParticleSystem: (ps) => this.sr.fixParticleSystem(ps),
},
lc: this.lightContainer,
root: this.root,
options: this.options,
model: this.model!,
timer: this.timer,
graphicsQuality: this.graphicsQuality,
reloadModel: () => {
this.reload();
},
});
}
public async reload() {
this.timer.dispose();
this.instance?.dispose?.();
this.instance = null;
this.model = null;
this.subRoot?.dispose();
this.root.removeChild(this.subRoot);
this.scene.removeTransformNode(this.subRoot);
this.timer = new Timer();
await this.load();
this.sr.disableSnapshotRendering();
this.sr.enableSnapshotRendering();
}
public optionsUpdated(options: Record<string, unknown>, key: string, value: any) {
if (this.instance == null) return;
this.options[key] = options[key]; // 参照を切れさせないようにプロパティ個別にmutate
this.sr.disableSnapshotRendering();
this.instance.onOptionsUpdated?.([key, this.options[key]]);
this.sr.enableSnapshotRendering();
}
public destroy() {
this.sr.disableSnapshotRendering();
this.timer.dispose();
this.instance?.dispose?.();
this.subRoot.dispose();
this.root.dispose();
this.scene.removeTransformNode(this.root);
this.sr.enableSnapshotRendering();
}
}
@@ -1,32 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { bolt_schema } from 'misskey-world/src/avatars/accessories/bolt.schema.js';
import { defineAccessory } from '../accessory.js';
export const bolt = defineAccessory(bolt_schema, {
createInstance: ({ model, options }) => {
const material = model.findMaterial('__X_BOLT__');
const applyMat = () => {
material.albedoColor = new BABYLON.Color3(options.mat.color[0], options.mat.color[1], options.mat.color[2]);
material.roughness = options.mat.roughness;
material.metallic = options.mat.metallic;
};
applyMat();
return {
onOptionsUpdated: ([k, v]) => {
switch (k) {
case 'mat': applyMat(); break;
}
},
dispose: () => {
},
};
},
});
@@ -1,17 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { mikan_schema } from 'misskey-world/src/avatars/accessories/mikan.schema.js';
import { defineAccessory } from '../accessory.js';
export const mikan = defineAccessory(mikan_schema, {
createInstance: ({ scene, root, sr }) => {
return {
dispose: () => {
},
};
},
});
@@ -1,71 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm } from 'misskey-world/src/utility.js';
import { mug_schema } from 'misskey-world/src/avatars/accessories/mug.schema.js';
import { defineAccessory } from '../accessory.js';
export const mug = defineAccessory(mug_schema, {
createInstance: ({ options, scene, root, sr, model }) => {
const emitter = new BABYLON.TransformNode('emitter', scene);
emitter.parent = root;
emitter.position = new BABYLON.Vector3(0, cm(5), 0);
const ps = new BABYLON.ParticleSystem('steamParticleSystem', 8, scene);
ps.particleTexture = new BABYLON.Texture('/client-assets/world/objects/mug/steam.png');
ps.emitter = emitter;
ps.minEmitBox = new BABYLON.Vector3(cm(-1), 0, cm(-1));
ps.maxEmitBox = new BABYLON.Vector3(cm(1), 0, cm(1));
ps.minEmitPower = cm(10);
ps.maxEmitPower = cm(12);
ps.minLifeTime = 2;
ps.maxLifeTime = 3;
ps.addSizeGradient(0, cm(10), cm(12));
ps.addSizeGradient(1, cm(18), cm(20));
ps.direction1 = new BABYLON.Vector3(-0.3, 1, 0.3);
ps.direction2 = new BABYLON.Vector3(0.3, 1, -0.3);
ps.emitRate = 0.5;
ps.blendMode = BABYLON.ParticleSystem.BLENDMODE_ADD;
ps.color1 = new BABYLON.Color4(1, 1, 1, 0.3);
ps.color2 = new BABYLON.Color4(1, 1, 1, 0.2);
ps.colorDead = new BABYLON.Color4(1, 1, 1, 0);
ps.preWarmCycles = Math.random() * 1000;
ps.start();
sr.fixParticleSystem(ps);
const bodyMaterial = model.findMaterial('__X_MUG__');
const applyBodyMat = () => {
bodyMaterial.albedoColor = new BABYLON.Color3(options.bodyMat.color[0], options.bodyMat.color[1], options.bodyMat.color[2]);
bodyMaterial.roughness = options.bodyMat.roughness;
bodyMaterial.metallic = options.bodyMat.metallic;
};
applyBodyMat();
const liquidMaterial = model.findMaterial('__X_LIQUID__');
const applyLiquidMat = () => {
liquidMaterial.albedoColor = new BABYLON.Color3(options.liquidMat.color[0], options.liquidMat.color[1], options.liquidMat.color[2]);
liquidMaterial.roughness = options.liquidMat.roughness;
liquidMaterial.metallic = options.liquidMat.metallic;
};
applyLiquidMat();
return {
onOptionsUpdated: ([k, v]) => {
switch (k) {
case 'bodyMat': applyBodyMat(); break;
case 'liquidMat': applyLiquidMat(); break;
}
},
dispose: () => {
ps.stop();
emitter.dispose();
},
};
},
});
@@ -1,23 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { mug } from './accessories/mug.js';
import { mikan } from './accessories/mikan.js';
import { bolt } from './accessories/bolt.js';
import type { AvatarAccessoryDef } from './accessory.js';
export const AVATAR_ACCESSORY_DEFS = [
mug,
mikan,
bolt,
] as AvatarAccessoryDef[];
export function getAccessoryDef(type: string): AvatarAccessoryDef {
const def = AVATAR_ACCESSORY_DEFS.find(x => x.id === type) as AvatarAccessoryDef | undefined;
if (def == null) {
throw new Error(`Unrecognized accessory type: ${type}`);
}
return def;
}
@@ -1,46 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { ModelExplorer, type Timer } from '../utility.js';
import type { AccessorySchemaDef } from 'misskey-world/src/avatars/accessory.js';
import type { OptionsSchema } from 'misskey-world/src/mono.js';
import type { ConvertedOptions, GetConvertedOptionsSchemaValues } from '../mono.js';
export type AvatarAccessoryInstance<Options = any> = {
onOptionsUpdated?: <K extends keyof Options, V extends Options[K]>(kv: [K, V]) => void;
dispose: () => void;
};
export type SnapshotRenderingHelperWrapper = {
updateMesh: (meshes: BABYLON.Mesh[]) => void;
reset: () => void;
fixParticleSystem: (ps: BABYLON.ParticleSystem) => void;
};
export type AvatarAccessoryDef<Schema extends AccessorySchemaDef = AccessorySchemaDef> = Schema & {
path?: (options: string extends keyof Schema['options']['schema'] ? ConvertedOptions : Readonly<GetConvertedOptionsSchemaValues<Schema['options']['schema']>>) => string;
createInstance: (args: {
scene: BABYLON.Scene;
// TODO: snapshot renderingの関心を隠蔽した方が綺麗かもしれない
// 例えばmaterialUpdatedというメソッドを用意して内部的にresetを呼ぶなど
sr: SnapshotRenderingHelperWrapper;
lc: BABYLON.ClusteredLightContainer | null;
root: BABYLON.TransformNode;
options: string extends keyof Schema['options']['schema'] ? ConvertedOptions : Readonly<GetConvertedOptionsSchemaValues<Schema['options']['schema']>>;
model: ModelExplorer;
timer: Timer;
graphicsQuality: number;
reloadModel: () => void;
}) => AvatarAccessoryInstance<string extends keyof Schema['options']['schema'] ? ConvertedOptions : GetConvertedOptionsSchemaValues<Schema['options']['schema']>> | Promise<AvatarAccessoryInstance<Schema['options']['schema'] extends undefined ? ConvertedOptions : GetConvertedOptionsSchemaValues<Schema['options']['schema']>>>; // TODO: createInstanceをasyncにするのではなく、別にreadyみたいなものを返させる
};
export function defineAccessorySchema<const OpSc extends OptionsSchema>(def: AccessorySchemaDef<OpSc>): AccessorySchemaDef<OpSc> {
return def;
}
export function defineAccessory<const Schema extends AccessorySchemaDef<any>>(schema: Schema, def: Pick<AvatarAccessoryDef<Schema>, 'path' | 'createInstance'>): AvatarAccessoryDef<Schema> {
return { ...schema, ...def };
}
@@ -1,38 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
export function registerBabylonRuntime(): void {
BABYLON.RegisterStandardEngineExtensions();
BABYLON.RegisterStandardWebGPUEngineExtensions();
BABYLON.RegisterAbstractEngineAlpha();
BABYLON.RegisterAbstractEngineTexture();
BABYLON.RegisterAbstractEngineCubeTexture();
BABYLON.RegisterAbstractEngineQuery();
BABYLON.RegisterAbstractEngineTextureSelector();
BABYLON.RegisterAbstractEngineTimeQuery();
BABYLON.RegisterAbstractEngineViews();
BABYLON.RegisterEnginesWebGPUExtensionsEngineRawTexture();
BABYLON.RegisterEnginesWebGPUExtensionsEngineReadTexture();
BABYLON.RegisterEnginesWebGPUExtensionsEngineCubeTexture();
BABYLON.RegisterEnginesWebGPUExtensionsEngineRenderTargetCube();
BABYLON.RegisterEnginesWebGPUExtensionsEngineQuery();
BABYLON.RegisterEnginesWebGPUExtensionsEngineDynamicTexture();
BABYLON.RegisterEnginesWebGPUExtensionsEngineVideoTexture();
BABYLON.RegisterBufferAlign();
BABYLON.RegisterCubeTexture();
BABYLON.RegisterStandardMaterial();
BABYLON.RegisterOutlineRenderer();
BABYLON.RegisterRay();
BABYLON.RegisterAnimation();
BABYLON.RegisterAnimatable();
BABYLON.RegisterCollisionCoordinator();
BABYLON.RegisterInstancedMesh();
BABYLON.RegisterThinInstanceMesh();
BABYLON.RegisterPostProcessRenderPipelineManagerSceneComponent(
BABYLON.PostProcessRenderPipelineManager,
);
}
@@ -1,25 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// structredCloneが遅いため
// SEE: http://var.blog.jp/archives/86038606.html
// あと、Vue RefをIndexedDBに保存しようとしてstructredCloneを使ったらエラーになった
// https://github.com/misskey-dev/misskey/pull/8098#issuecomment-1114144045
export type Cloneable = string | number | boolean | null | undefined | { [key: string]: Cloneable } | { [key: number]: Cloneable } | { [key: symbol]: Cloneable } | Cloneable[];
export function deepClone<T extends Cloneable>(x: T): T {
if (typeof x === 'object') {
if (x === null) return x;
if (Array.isArray(x)) return x.map(deepClone) as T;
const obj = {} as Record<string | number | symbol, Cloneable>;
for (const [k, v] of Object.entries(x)) {
obj[k] = v === undefined ? undefined : deepClone(v);
}
return obj as T;
} else {
return x;
}
}
@@ -1,247 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { registerBuiltInLoaders } from '@babylonjs/loaders/dynamic.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { FreeCameraManualInput, GRAPHICS_QUALITY, Timer } from './utility.js';
import { TIME_MAP } from './utility.js';
import { MultiplayEngineBase } from './MultiplayEngineBase.js';
import { CelShadingRenderer } from './CelShadingRenderer.js';
import { LobbyEnvManager } from './envs/lobby.js';
import type { PlayerContainer, PlayerProfile, PlayerState } from './PlayerContainer.js';
import type { WorldEnvManager } from './env.js';
const IN_WEB_WORKER = typeof window === 'undefined';
export class WorldEngine extends MultiplayEngineBase<{
'changeMyPlayerState': (ctx: PlayerState) => void;
'playerPointed': (ctx: { playerId: string; }) => void;
'playSfxUrl': (ctx: {
url: string;
options: {
volume: number;
playbackRate: number;
};
}) => void;
'loadingProgress': (ctx: { progress: number }) => void;
'contextlost': (ctx: { reason: string; message: string; }) => void;
}> {
private dayPeriod: 0 | 1 | 2 = 0; // 0: 昼, 1: 夕, 2: 夜
public lightContainer: BABYLON.ClusteredLightContainer;
public sr: BABYLON.SnapshotRenderingHelper;
public readonly celShadingRenderer: CelShadingRenderer;
public gl: BABYLON.GlowLayer | null = null;
public timer: Timer = new Timer();
private useGlow: boolean;
public graphicsQuality: number;
private envManager: WorldEnvManager | null = null;
private inited = false;
constructor(options: {
babylonEngine: BABYLON.WebGPUEngine;
graphicsQuality: number;
fps: number | null;
antialias: boolean;
fov: number;
useVirtualJoystick?: boolean;
showUsernameOnAvatar: boolean;
show2dAvatarOnAvatar: boolean;
}) {
super({
babylonEngine: options.babylonEngine,
fps: options.fps,
showUsernameOnAvatar: options.showUsernameOnAvatar,
show2dAvatarOnAvatar: options.show2dAvatarOnAvatar,
useVirtualJoystick: options.useVirtualJoystick ?? false,
fov: options.fov,
fastMovement: true,
});
this.graphicsQuality = options.graphicsQuality;
this.useGlow = this.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM;
registerBuiltInLoaders();
this.scene.autoClear = false;
//this.scene.autoClearDepthAndStencil = false;
this.scene.skipPointerMovePicking = true;
this.scene.skipFrustumClipping = true; // snapshot renderingでは全てのメッシュがアクティブになっている必要があるため
this.scene.gravity = new BABYLON.Vector3(0, -0.1, 0).scale(WORLD_SCALE);
this.scene.collisionsEnabled = true;
this.celShadingRenderer = new CelShadingRenderer(this.scene, {
enabled: true,
color: new BABYLON.Color3(0.5, 0.6, 0.7),
width: cm(2),
});
this.sr = new BABYLON.SnapshotRenderingHelper(this.scene);
this.dayPeriod = TIME_MAP[new Date().getHours() as keyof typeof TIME_MAP];
//this.time = TIME_MAP[12 as keyof typeof TIME_MAP];
this.scene.ambientColor = new BABYLON.Color3(0.9, 0.9, 0.9);
this.lightContainer = new BABYLON.ClusteredLightContainer('clustered', [], this.scene);
this.lightContainer.maxRange = cm(10000);
this.lightContainer.verticalTiles = 32;
this.lightContainer.horizontalTiles = 32;
this.lightContainer.depthSlices = 32;
if (this.useGlow) {
this.gl = new BABYLON.GlowLayer('glow', this.scene, {
//mainTextureFixedSize: 512,
blurKernelSize: 64,
});
this.gl.intensity = 0.5;
this.scene.setRenderingAutoClearDepthStencil(this.gl.renderingGroupId, false);
this.sr.updateMeshesForEffectLayer(this.gl);
}
if (this.graphicsQuality >= GRAPHICS_QUALITY.HIGH) {
const pipeline = new BABYLON.DefaultRenderingPipeline('default', true, this.scene);
if (options.antialias) {
pipeline.samples = 4;
}
pipeline.bloomEnabled = true;
pipeline.bloomThreshold = 0.95;
pipeline.bloomWeight = 0.3;
pipeline.bloomKernel = 256;
pipeline.bloomScale = 2;
pipeline.sharpenEnabled = true;
pipeline.sharpen.edgeAmount = 0.5;
}
}
public async init() {
await this.loadEnv();
this.startRenderLoop();
await this.scene.whenReadyAsync();
// 必ずシーンが少なくとも1フレームレンダリングがされてから呼ばれるように注意すること。そうしないとタイミングによってはエンジンがクラッシュする
this.sr.enableSnapshotRendering();
this.inputs.on('wheel', (ev) => {
if (this.scene.activeCamera === this.camera) {
this.camera.fov += ev.deltaY * 0.001;
this.camera.fov = Math.max(0.25, Math.min(this.fov, this.camera.fov));
}
});
this.inputs.on('zoom', (ev) => {
if (this.scene.activeCamera === this.camera) {
this.camera.fov += -ev.delta * 0.003;
this.camera.fov = Math.max(0.25, Math.min(this.fov, this.camera.fov));
}
});
this.inputs.on('click', (ev) => {
// TODO: GPUPickerを使いたいが、なぜか一部のメッシュが反応しない
const pickingInfo = this.scene.pick(ev.x, ev.y,
(m) => m.name.includes('__PICK__') || m.metadata?.isPlayer || (m.isVisible && m.isEnabled() && m.metadata?.furnitureId != null && this.furnitureContainers.has(m.metadata.furnitureId)));
if (pickingInfo.pickedMesh != null) {
const playerId = pickingInfo.pickedMesh.metadata.playerId;
if (playerId != null && this.playerContainers.some(c => c.id === playerId)) {
const c = this.playerContainers.find(c => c.id === playerId)!;
this.look(c.root.position);
this.ev('playerPointed', { playerId });
return;
}
}
});
this.inputs.on('pointer', (ev) => {
if (this.scene.activeCamera === this.camera) {
(this.camera.inputs.attached.manual as FreeCameraManualInput).setRotationVector({ x: ev.x, y: ev.y });
}
});
this.timer.setInterval(() => {
const camera = this.scene.activeCamera!;
const myPos = camera.globalPosition;
const myRotation = camera.absoluteRotation.toEulerAngles();
this.ev('changeMyPlayerState', {
position: [myPos.x, myPos.y, myPos.z],
rotation: [myRotation.x, myRotation.y, myRotation.z],
});
}, 100);
this.inited = true;
}
private async loadEnv() {
const envManager = new LobbyEnvManager(this);
await envManager.load();
envManager.applyDayPeriod(this.dayPeriod);
for (const mat of this.scene.materials) {
mat.unfreeze();
if (mat instanceof BABYLON.MultiMaterial) {
for (const subMat of mat.subMaterials) {
if (subMat.metadata?.useEnvMap) subMat.reflectionTexture = envManager.envMapIndoor;
}
} else {
if (mat.metadata?.useEnvMap) mat.reflectionTexture = envManager.envMapIndoor;
}
}
this.envManager = envManager;
this.camera.maxZ = this.envManager.maxCameraZ;
}
public getEnvMap(): BABYLON.CubeTexture | null {
return this.envManager?.envMapIndoor ?? null;
}
public cameraMove(vector: { x: number; y: number; }, dash: boolean) {
(this.camera.inputs.attached.manual as FreeCameraManualInput).setMoveVector(dash ? { x: vector.x * 3, y: vector.y * 3 } : vector);
}
public cameraJoystickMove(vector: { x: number; y: number; }) {
(this.camera.inputs.attached.manual as FreeCameraManualInput).setMoveVector(vector);
}
private playSfxUrl(url: string, options: { volume: number; playbackRate: number }) {
this.emit('playSfxUrl', { url, options });
}
public clearPlayers() {
this.sr.disableSnapshotRendering();
for (const playerContainer of this.playerContainers) {
playerContainer.destroy();
}
this.sr.enableSnapshotRendering();
this.playerContainers = [];
}
public updateAvatarDisplayOptions(options: { showUsername: boolean; show2dAvatar: boolean }) {
this.showUsernameOnAvatar = options.showUsername;
this.show2dAvatarOnAvatar = options.show2dAvatar;
this.sr.disableSnapshotRendering();
for (const playerContainer of this.playerContainers) {
playerContainer.updateUserInfoDisplayOptions(options);
}
this.sr.enableSnapshotRendering();
}
public resize() {
this.babylonEngine.resize(true);
}
public destroy() {
this.celShadingRenderer.dispose();
super.destroy();
this.timer.dispose();
this.envManager.dispose();
}
}
@@ -1,109 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { findMaterial, GRAPHICS_QUALITY } from './utility.js';
import type { WorldEngine } from './engine.js';
export abstract class WorldEnvManager {
protected engine: WorldEngine;
public abstract envMapIndoor: BABYLON.CubeTexture | null;
public abstract maxCameraZ: number;
private shadowGenerators: BABYLON.ShadowGenerator[] = [];
constructor(engine: WorldEngine) {
this.engine = engine;
}
abstract load(): Promise<void>;
abstract applyDayPeriod(dayPeriod: number): void;
protected registerShadowGenerator(shadowGenerator: BABYLON.ShadowGenerator) {
this.shadowGenerators.push(shadowGenerator);
const shadowMap = shadowGenerator.getShadowMap()!;
shadowMap.refreshRate = BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
// https://forum.babylonjs.com/t/is-it-intentional-that-the-shadow-map-refresh-rate-is-ignored-under-fast-snapshot-rendering/63523
const objectRenderer = shadowMap._objectRenderer;
const originalShouldRender = objectRenderer.shouldRender.bind(objectRenderer);
objectRenderer.shouldRender = function () {
if (this._engine.snapshotRendering) {
return this.refreshRate !== BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
}
return originalShouldRender();
};
}
public addShadowCaster(mesh: BABYLON.AbstractMesh) {
for (const shadowGen of this.shadowGenerators) {
shadowGen.addShadowCaster(mesh);
}
}
public removeShadowCaster(mesh: BABYLON.AbstractMesh) {
for (const shadowGen of this.shadowGenerators) {
shadowGen.removeShadowCaster(mesh);
}
}
public async renderShadow() {
this.engine.sr.disableSnapshotRendering();
for (const shadowGen of this.shadowGenerators) {
const shadowMap = shadowGen.getShadowMap()!;
shadowMap.refreshRate = 1;
}
await new Promise(resolve => setTimeout(resolve, 1));
for (const shadowGen of this.shadowGenerators) {
const shadowMap = shadowGen.getShadowMap()!;
shadowMap.refreshRate = BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
}
this.engine.sr.enableSnapshotRendering();
}
protected registerMeshes(meshes: BABYLON.AbstractMesh[]) {
for (const mesh of meshes) {
if (!this.engine.scene.meshes.includes(mesh)) this.engine.scene.addMesh(mesh);
if (['__COLLISION__'].some(name => mesh.name.includes(name))) {
mesh.isPickable = false;
mesh.receiveShadows = false;
mesh.isVisible = false;
mesh.checkCollisions = false;
if (mesh.name.includes('__COLLISION__')) {
mesh.checkCollisions = true;
}
continue;
}
mesh.isPickable = false;
mesh.checkCollisions = false;
if (mesh.material != null) {
(mesh.material as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
if (mesh.material instanceof BABYLON.MultiMaterial) {
for (const subMat of mesh.material.subMaterials) {
subMat.reflectionTexture = this.envMapIndoor;
}
} else if (mesh.material instanceof BABYLON.PBRMaterial) {
mesh.material.reflectionTexture = this.envMapIndoor;
}
}
}
}
public dispose() {
for (const shadowGen of this.shadowGenerators) {
shadowGen.dispose();
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,25 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// ランダムな文字列が生成できればなんでも良い(時系列でソートできるなら尚良)が、とりあえずaidの実装を拝借
const TIME2000 = 946684800000;
let counter = Math.floor(Math.random() * 10000);
function getTime(time: number): string {
time = time - TIME2000;
if (time < 0) time = 0;
return time.toString(36).padStart(8, '0');
}
function getNoise(): string {
return counter.toString(36).padStart(2, '0').slice(-2);
}
export function genId(): string {
counter++;
return getTime(Date.now()) + getNoise();
}
@@ -1,52 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { OptionsSchema, NumberOptionSchema, BooleanOptionSchema, StringOptionSchema, ColorOptionSchema, MaterialOptionSchema, LightOptionSchema, EnumOptionSchema, RangeOptionSchema, ImageOptionSchema, SeedOptionSchema } from 'misskey-world/src/mono.js';
export type RawOptions = Record<string, unknown> & {
readonly __brand: unique symbol;
};
export type ConvertedOptions = Record<string, unknown> & {
readonly __brand: unique symbol;
};
type RawImageValue<Presets extends string = string> = { type: Presets | null | '_custom_'; driveFileId?: string | null; fit?: 'cover' | 'contain' | 'stretch'; rotation?: 0 | 1 | 2 | 3; };
type ConvertedImageValue<Presets extends string = string> = { type: Presets | null | '_custom_'; custom?: { url: string; } | null; fit?: 'cover' | 'contain' | 'stretch'; rotation?: 0 | 1 | 2 | 3; };
export type GetConvertedOptionsSchemaValues<T extends OptionsSchema> = {
[K in keyof T]:
T[K] extends NumberOptionSchema ? number :
T[K] extends BooleanOptionSchema ? boolean :
T[K] extends StringOptionSchema ? string :
T[K] extends ColorOptionSchema ? [number, number, number] :
T[K] extends MaterialOptionSchema ? { color: [number, number, number]; metallic: number; roughness: number; } :
T[K] extends LightOptionSchema ? { color: [number, number, number]; brightness: number; } :
T[K] extends EnumOptionSchema ? T[K]['enum'][number]['value'] :
T[K] extends RangeOptionSchema ? number :
T[K] extends ImageOptionSchema ? ConvertedImageValue<T[K]['presets'][number]['value']> :
T[K] extends SeedOptionSchema ? number :
never;
};
export function convertRawOptions<OpSc extends OptionsSchema>(schema: OpSc, raw: RawOptions, attachments: { files: { id: string; url: string; }[] }): ConvertedOptions {
const converted = {} as ConvertedOptions;
for (const record of Object.entries(schema)) {
const k = record[0];
const v = raw[k];
if (record[1].type === 'image') {
const _v = v as unknown as RawImageValue;
const file = _v.type === '_custom_' ? attachments.files.find(f => f.id === _v.driveFileId) : null;
if (file != null && file.url.startsWith('http://syu-win.local:3000/')) { // debug
file.url = file.url.replace('http://syu-win.local:3000/', 'https://local-mi.syuilo.dev/');
}
converted[k] = { type: _v.type, custom: file != null ? { url: file.url } : null, fit: _v.fit, rotation: _v.rotation } satisfies ConvertedImageValue;
} else {
converted[k] = v;
}
}
return converted;
}
@@ -1,38 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { registerBabylonRuntime } from './babylonRuntime.js';
import { WorldEngine } from './engine.js';
registerBabylonRuntime();
export async function createWorldEngine(params: {
canvas: HTMLCanvasElement;
options: {
antialias: boolean;
resolution: number;
fov: number;
graphicsQuality: number;
fps: number | null;
useVirtualJoystick?: boolean;
showUsernameOnAvatar: boolean;
show2dAvatarOnAvatar: boolean;
};
}) {
const babylonEngine = new BABYLON.WebGPUEngine(params.canvas, { doNotHandleContextLost: true, powerPreference: 'high-performance', antialias: params.options.antialias });
babylonEngine.compatibilityMode = false;
babylonEngine.enableOfflineSupport = false;
await babylonEngine.initAsync();
if (params.options.resolution === 2) babylonEngine.setHardwareScalingLevel(0.5);
if (params.options.resolution === 0.5) babylonEngine.setHardwareScalingLevel(2);
const engine = new WorldEngine({
babylonEngine: babylonEngine,
...params.options,
});
return engine;
}
@@ -1,280 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { camelToKebab, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { scaleMorph, Timer } from '../utility.js';
import { convertRawOptions, type ConvertedOptions, type RawOptions } from '../mono.js';
import { getFurnitureDef } from './furniture-defs.js';
import { ModelManager, SYSTEM_MESH_NAMES } from './utility.js';
import type { RoomFurnitureInstance } from './furniture.js';
import type { RoomAttachments } from 'misskey-world/src/room/type.js';
function mergeMeshes(meshes: BABYLON.Mesh[], root: BABYLON.Mesh, hasTexture: boolean) {
const excludeMeshes = root.getChildMeshes().filter(m => SYSTEM_MESH_NAMES.some(s => m.name.includes(s)));
const childMeshes = root.getChildMeshes().filter(m => !excludeMeshes.some(x => x === m) && m.isVisible && !m.isDisposed());
const toMerge = [] as BABYLON.Mesh[];
for (const mesh of childMeshes) {
if (mesh instanceof BABYLON.InstancedMesh) {
continue;
}
if (mesh.hasInstances) continue;
if (mesh instanceof BABYLON.Mesh) {
toMerge.push(mesh);
}
}
if (toMerge.length <= 1) { // マージ対象が一つしかない状態でマージするのは単純に無駄なのと、babylonのバグが知らないけどなぜか法線が反転する
return null;
}
for (const mesh of toMerge) {
if (hasTexture) {
if (mesh.getVerticesData(BABYLON.VertexBuffer.UVKind) == null) {
const vertexCount = mesh.getTotalVertices();
const uvs = new Array(vertexCount * 2).fill(0);
mesh.setVerticesData(BABYLON.VertexBuffer.UVKind, uvs, false, 2);
}
if (mesh.getVerticesData(BABYLON.VertexBuffer.UV2Kind) == null) {
const vertexCount = mesh.getTotalVertices();
const uvs = new Array(vertexCount * 2).fill(0);
mesh.setVerticesData(BABYLON.VertexBuffer.UV2Kind, uvs, false, 2);
}
}
}
const merged = BABYLON.Mesh.MergeMeshes(toMerge, true, false, undefined, false, true);
return merged;
}
export class FurnitureContainer {
public id: string;
public type: string;
private options: ConvertedOptions;
public root: BABYLON.TransformNode;
private subRoot: BABYLON.TransformNode | null = null;
public instance: RoomFurnitureInstance | null = null;
public model: ModelManager | null = null;
private scene: BABYLON.Scene;
public registerMeshes: (meshes: BABYLON.Mesh[]) => void = () => {};
private sr: BABYLON.SnapshotRenderingHelper;
private getIsSrReady: () => boolean;
private lightContainer: BABYLON.ClusteredLightContainer;
private graphicsQuality: number;
private timer: Timer = new Timer();
private sitChair: () => void = () => {};
public boundingBox: {
min: BABYLON.Vector3;
max: BABYLON.Vector3;
} | null = null;
constructor(args: {
id: string;
type: string;
options: RawOptions;
roomAttachments: RoomAttachments;
position: BABYLON.Vector3;
rotation: BABYLON.Vector3;
sr: BABYLON.SnapshotRenderingHelper;
getIsSrReady: () => boolean;
lightContainer: BABYLON.ClusteredLightContainer;
scene: BABYLON.Scene;
graphicsQuality: number;
sitChair?: () => void;
}) {
this.id = args.id;
this.type = args.type;
const def = getFurnitureDef(this.type);
this.options = convertRawOptions(def.options.schema, args.options, args.roomAttachments);
this.sr = args.sr;
this.getIsSrReady = args.getIsSrReady;
this.lightContainer = args.lightContainer;
this.scene = args.scene;
this.graphicsQuality = args.graphicsQuality;
this.root = new BABYLON.TransformNode(`furniture_${args.id}_${args.type}`, this.scene);
this.root.position = args.position;
this.root.rotation = args.rotation;
if (args.sitChair != null) this.sitChair = args.sitChair;
}
public async load() {
const def = getFurnitureDef(this.type);
const filePath = def.path != null ? `/client-assets/world/objects/${def.path(this.options)}.glb` : `/client-assets/world/objects/${camelToKebab(this.type)}/${camelToKebab(this.type)}.glb`;
const loaderResult = await BABYLON.LoadAssetContainerAsync(filePath, this.scene);
// babylonによって自動で追加される右手系変換用ノード
const subRootMesh = loaderResult.meshes[0] as BABYLON.Mesh;
// 不要なUVを掃除
if (!def.hasTexture) {
for (const m of loaderResult.meshes) {
if (m.geometry != null) {
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UVKind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV2Kind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV3Kind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV4Kind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV5Kind);
m.geometry.removeVerticesData(BABYLON.VertexBuffer.UV6Kind);
}
}
}
if (def.canPreMeshesMerging) {
const merged = mergeMeshes(loaderResult.meshes, subRootMesh, def.hasTexture);
if (merged != null) {
merged.setParent(subRootMesh);
merged.name = 'preMerged';
merged.material.freeze();
if (merged.material instanceof BABYLON.MultiMaterial) {
for (const subMat of merged.material.subMaterials) {
subMat.freeze();
}
}
// TODO: 再帰的にする
for (const m of loaderResult.transformNodes) {
if (m.getChildren().length === 0) {
m.dispose();
}
}
}
}
// meshじゃなくtransform nodeにしてパフォーマンス向上
this.subRoot = new BABYLON.TransformNode('__root__', this.scene);
this.subRoot.parent = this.root;
this.subRoot.scaling.x = -1;
this.subRoot.scaling = this.subRoot.scaling.scale(WORLD_SCALE);// cmをmに
for (const m of subRootMesh.getChildren()) {
if (m.parent === subRootMesh) {
m.parent = this.subRoot;
}
}
subRootMesh.dispose();
this.registerMeshes(this.subRoot.getChildMeshes());
this.model = new ModelManager(this.subRoot, loaderResult.meshes.filter(m => !m.isDisposed() && m.name !== '__root__'), def.hasTexture, (meshes) => {
this.registerMeshes(meshes);
});
this.instance = await def.createInstance({
scene: this.scene,
sr: {
updateMesh: (mesh) => {
if (!this.getIsSrReady()) return;
this.sr.updateMesh(mesh);
},
reset: () => {
if (!this.getIsSrReady()) return;
this.sr.disableSnapshotRendering();
this.sr.enableSnapshotRendering();
},
fixParticleSystem: (ps) => this.sr.fixParticleSystem(ps),
},
lc: this.lightContainer,
root: this.root,
options: this.options,
model: this.model!,
id: this.id,
timer: this.timer,
graphicsQuality: this.graphicsQuality,
reloadModel: () => {
this.reload();
},
sitChair: () => {
this.sitChair();
},
stickyMarkerMeshUpdated: (mesh) => {
// TODO
//// stickyな子の位置を更新
//if (mesh.name.includes('__TOP__')) {
// mesh.unfreezeWorldMatrix();
// mesh.computeWorldMatrix(true);
// const updateChildStickyObjectPosition = (furnitureId: string) => {
// const stickyFurnitureIds = Array.from(this.roomState.installedFurnitures.filter(o => o.sticky === furnitureId)).map(o => o.id);
// for (const soid of stickyFurnitureIds) {
// const soMesh = this.objectEntities.get(soid)!.rootMesh;
// soMesh.unfreezeWorldMatrix();
// for (const m of soMesh.getChildMeshes()) {
// m.unfreezeWorldMatrix();
// }
// console.log(mesh.getAbsolutePosition().y);
// soMesh.position.y = mesh.getAbsolutePosition().y;
// updateChildStickyObjectPosition(soid);
// }
// };
// updateChildStickyObjectPosition(args.id);
//}
},
});
this.instance.onInited?.();
this.calcBoundingBox();
}
public calcBoundingBox() {
// TODO: モーフ最大適用後のサイズが取得されてしまうのを直す
this.boundingBox = this.subRoot.getHierarchyBoundingVectors(true);
}
public interact(iid: string | null = null) {
if (this.instance == null) return;
if (iid == null) {
if (this.instance.primaryInteraction != null) {
this.instance.interactions[this.instance.primaryInteraction].fn();
}
} else {
this.instance.interactions[iid].fn();
}
}
public async reload() {
this.timer.dispose();
this.instance?.dispose?.();
this.instance = null;
this.model = null;
this.subRoot?.dispose();
this.root.removeChild(this.subRoot);
this.scene.removeTransformNode(this.subRoot);
this.timer = new Timer();
await this.load();
this.sr.disableSnapshotRendering();
this.sr.enableSnapshotRendering();
}
public optionsUpdated(options: RawOptions, key: string, value: any, roomAttachments: RoomAttachments) {
if (this.instance == null) return;
const def = getFurnitureDef(this.type);
const convertedOptions = convertRawOptions(def.options.schema, options, roomAttachments);
this.options[key] = convertedOptions[key]; // 参照を切れさせないようにプロパティ個別にmutate
this.sr.disableSnapshotRendering();
this.instance.onOptionsUpdated?.([key, this.options[key]]);
this.sr.enableSnapshotRendering();
}
public destroy() {
this.sr.disableSnapshotRendering();
this.timer.dispose();
this.instance?.dispose?.();
this.subRoot.dispose();
this.root.dispose();
this.scene.removeTransformNode(this.root);
this.sr.enableSnapshotRendering();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,121 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { SYSTEM_HEYA_MESH_NAMES } from './utility.js';
import type { RoomEngine } from './engine.js';
export abstract class RoomEnvManager<T = any> {
protected engine: RoomEngine;
public abstract envMapIndoor: BABYLON.CubeTexture | null;
public abstract maxCameraZ: number;
private shadowGenerators: BABYLON.ShadowGenerator[] = [];
protected isRoomLightOn = true;
constructor(engine: RoomEngine) {
this.engine = engine;
}
abstract load(options: T): Promise<void>;
abstract applyOptions(options: T): void;
abstract applyDayPeriod(dayPeriod: number): void;
abstract applyRoomLight(): void;
public turnOnRoomLight() {
this.isRoomLightOn = true;
this.applyRoomLight();
}
public turnOffRoomLight() {
this.isRoomLightOn = false;
this.applyRoomLight();
}
protected registerShadowGenerator(shadowGenerator: BABYLON.ShadowGenerator) {
this.shadowGenerators.push(shadowGenerator);
const shadowMap = shadowGenerator.getShadowMap()!;
shadowMap.refreshRate = BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
// https://forum.babylonjs.com/t/is-it-intentional-that-the-shadow-map-refresh-rate-is-ignored-under-fast-snapshot-rendering/63523
const objectRenderer = shadowMap._objectRenderer;
const originalShouldRender = objectRenderer.shouldRender.bind(objectRenderer);
objectRenderer.shouldRender = function () {
if (this._engine.snapshotRendering) {
return this.refreshRate !== BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
}
return originalShouldRender();
};
}
public addShadowCaster(mesh: BABYLON.AbstractMesh) {
for (const shadowGen of this.shadowGenerators) {
shadowGen.addShadowCaster(mesh);
}
}
public removeShadowCaster(mesh: BABYLON.AbstractMesh) {
for (const shadowGen of this.shadowGenerators) {
shadowGen.removeShadowCaster(mesh);
}
}
public async renderShadow() {
this.engine.sr.disableSnapshotRendering();
for (const shadowGen of this.shadowGenerators) {
const shadowMap = shadowGen.getShadowMap()!;
shadowMap.refreshRate = 1;
}
await new Promise(resolve => setTimeout(resolve, 1));
for (const shadowGen of this.shadowGenerators) {
const shadowMap = shadowGen.getShadowMap()!;
shadowMap.refreshRate = BABYLON.RenderTargetTexture.REFRESHRATE_RENDER_ONCE;
}
this.engine.sr.enableSnapshotRendering();
}
protected registerMeshes(meshes: BABYLON.AbstractMesh[]) {
for (const mesh of meshes) {
if (!this.engine.scene.meshes.includes(mesh)) this.engine.scene.addMesh(mesh);
if (SYSTEM_HEYA_MESH_NAMES.some(name => mesh.name.includes(name))) {
mesh.isPickable = false;
mesh.receiveShadows = false;
mesh.isVisible = false;
mesh.checkCollisions = false;
if (mesh.name.includes('__COLLISION__')) {
mesh.checkCollisions = true;
}
continue;
}
mesh.isPickable = false;
mesh.checkCollisions = false;
if (mesh.material != null) {
(mesh.material as BABYLON.PBRMaterial).useGLTFLightFalloff = true; // Clustered Lightingではphysical falloffを持つマテリアルはアーチファクトが発生する https://doc.babylonjs.com/features/featuresDeepDive/lights/clusteredLighting/#materials-with-a-physical-falloff-may-cause-artefacts
if (mesh.material instanceof BABYLON.MultiMaterial) {
for (const subMat of mesh.material.subMaterials) {
subMat.reflectionTexture = this.envMapIndoor;
}
} else if (mesh.material instanceof BABYLON.PBRMaterial) {
mesh.material.reflectionTexture = this.envMapIndoor;
}
}
}
}
public dispose() {
for (const shadowGen of this.shadowGenerators) {
shadowGen.dispose();
}
}
}
@@ -1,343 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { findMaterial, GRAPHICS_QUALITY } from '../../utility.js';
import { SYSTEM_HEYA_MESH_NAMES } from '../utility.js';
import { RoomEnvManager } from '../env.js';
import type { RoomEngine } from '../engine.js';
import type { CustomMadoriEnvOptions } from 'misskey-world/src/room/env.js';
export class CustomMadoriEnvManager extends RoomEnvManager<CustomMadoriEnvOptions> {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private meshes: BABYLON.Mesh[] = [];
private rootNode: BABYLON.TransformNode;
private unitRootNodes: (BABYLON.TransformNode | null)[] = [];
private floorRootNode: BABYLON.TransformNode | null = null;
private wallRootNode: BABYLON.TransformNode | null = null;
private floorMaterials: Record<string, BABYLON.PBRMaterial> = {};
private wallMaterials: Record<string, BABYLON.PBRMaterial> = {};
private wallBeamMaterials: Record<string, BABYLON.PBRMaterial> = {};
private pillarMaterials: Record<string, BABYLON.PBRMaterial> = {};
private ceilingMaterials: Record<string, BABYLON.PBRMaterial> = {};
private beamMesh: BABYLON.Mesh | null = null;
private baseboardMesh: BABYLON.Mesh | null = null;
private wallARootNode: BABYLON.TransformNode | null = null;
private wallBRootNode: BABYLON.TransformNode | null = null;
private skybox: BABYLON.Mesh | null = null;
private skyboxMat: BABYLON.StandardMaterial | null = null;
private roomLight: BABYLON.DirectionalLight | null = null;
public envMapIndoor: BABYLON.CubeTexture | null = null;
public maxCameraZ = cm(3000);
constructor(engine: RoomEngine) {
super(engine);
this.rootNode = new BABYLON.TransformNode('customMadoriRoot', this.engine.scene);
//this.rootNode.scaling = new BABYLON.Vector3(WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
}
public async load(options: CustomMadoriEnvOptions) {
this.skybox = BABYLON.MeshBuilder.CreateBox('skybox', { size: cm(3000) }, this.engine.scene);
this.skyboxMat = new BABYLON.StandardMaterial('skyboxMat', this.engine.scene);
this.skyboxMat.backFaceCulling = false;
this.skyboxMat.disableLighting = true;
this.skybox.material = this.skyboxMat;
this.skybox.infiniteDistance = true;
this.roomLight = new BABYLON.DirectionalLight('env:RoomLight', new BABYLON.Vector3(0, -1, 0), this.engine.scene);
this.roomLight.position = new BABYLON.Vector3(0, cm(300), 0);
this.roomLight.shadowMinZ = cm(10);
this.roomLight.shadowMaxZ = cm(500);
this.roomLight.radius = cm(30);
this.applyRoomLight();
if (this.engine.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM) {
const shadowGeneratorForRoomLight = new BABYLON.ShadowGenerator(this.engine.graphicsQuality <= GRAPHICS_QUALITY.MEDIUM ? 1024 : 2048, this.roomLight);
shadowGeneratorForRoomLight.forceBackFacesOnly = true;
shadowGeneratorForRoomLight.bias = 0.0005;
shadowGeneratorForRoomLight.usePercentageCloserFiltering = true;
shadowGeneratorForRoomLight.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
//shadowGeneratorForRoomLight.useContactHardeningShadow = true;
//shadowGeneratorForRoomLight.contactHardeningLightSizeUVRatio = 0.01;
this.registerShadowGenerator(shadowGeneratorForRoomLight);
}
for (const materialDef of options.flooringMaterials) {
const mat = new BABYLON.PBRMaterial(`flooring_${materialDef.id}`, this.engine.scene);
mat.albedoColor = new BABYLON.Color3(...materialDef.color);
mat.metallic = 0;
mat.roughness = 1;
const texPath = materialDef.texture === 'wood' ? '/client-assets/room/textures/flooring-wood.png'
: materialDef.texture === 'concrete' ? '/client-assets/room/textures/concrete3.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
mat.albedoTexture = tex;
}
//mat.freeze();
this.floorMaterials[materialDef.id] = mat;
}
for (const materialDef of options.wallMaterials) {
const mat = new BABYLON.PBRMaterial(`wall_${materialDef.id}`, this.engine.scene);
mat.albedoColor = new BABYLON.Color3(...materialDef.color);
mat.metallic = 0;
mat.roughness = 1;
const texPath = materialDef.texture === 'wood' ? '/client-assets/room/textures/wall-wood2.png'
: materialDef.texture === 'concrete' ? '/client-assets/room/textures/concrete1.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
mat.albedoTexture = tex;
}
//mat.freeze();
this.wallMaterials[materialDef.id] = mat;
}
for (const materialDef of options.ceilingMaterials) {
const mat = new BABYLON.PBRMaterial(`ceiling_${materialDef.id}`, this.engine.scene);
mat.albedoColor = new BABYLON.Color3(...materialDef.color);
mat.metallic = 0;
mat.roughness = 1;
const texPath = materialDef.texture === 'wood' ? '/client-assets/room/textures/ceiling-wood.png'
: materialDef.texture === 'concrete' ? '/client-assets/room/textures/concrete3.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
mat.albedoTexture = tex;
}
//mat.freeze();
this.ceilingMaterials[materialDef.id] = mat;
}
this.loaderResult = await BABYLON.LoadAssetContainerAsync('/client-assets/room/envs/custom-madori/units.glb', this.engine.scene);
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.engine.scene);
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(2000), cm(500), cm(2000));
this.meshes = this.loaderResult.meshes.filter(m => m instanceof BABYLON.Mesh);
this.meshes[0].rotationQuaternion = null;
this.meshes[0].rotation = new BABYLON.Vector3(0, 0, 0);
for (const m of this.meshes[0].getChildren()) {
if (m.parent === this.meshes[0]) {
m.parent = this.rootNode;
}
}
// instanced mesh を通常の mesh に変換 (そうしないとマテリアルが共有される)
for (const mesh of this.loaderResult.meshes) {
if (mesh instanceof BABYLON.InstancedMesh) {
const realizedMesh = mesh.sourceMesh.clone(mesh.name, null, true);
realizedMesh.position = mesh.position.clone();
if (mesh.rotationQuaternion) {
realizedMesh.rotationQuaternion = mesh.rotationQuaternion.clone();
} else {
realizedMesh.rotation = mesh.rotation.clone();
}
realizedMesh.scaling = mesh.scaling.clone();
realizedMesh.parent = mesh.parent;
mesh.dispose();
this.engine.scene.removeMesh(mesh);
this.meshes.push(realizedMesh);
}
}
this.floorRootNode = this.loaderResult.transformNodes.find(t => t.name.includes('__FLOOR__'))!;
this.wallRootNode = this.loaderResult.transformNodes.find(t => t.name.includes('__WALL__'))!;
this.beamMesh = this.loaderResult.meshes.find(m => m.name.includes('__BEAM__')) as BABYLON.Mesh;
this.baseboardMesh = this.loaderResult.meshes.find(m => m.name.includes('__BASEBOARD__')) as BABYLON.Mesh;
this.wallARootNode = this.loaderResult.transformNodes.find(t => t.name.includes('__WALL_A__'))!;
this.wallBRootNode = this.loaderResult.transformNodes.find(t => t.name.includes('__WALL_B__'))!;
const baseboardMaterial = findMaterial(this.rootNode, '__BASEBOARD__');
//baseboardMaterial.metadata.disableEnvMap = true;
for (const mesh of this.meshes) {
if (SYSTEM_HEYA_MESH_NAMES.some(name => mesh.name.includes(name))) continue;
mesh.receiveShadows = true;
}
await this.applyOptions(options);
}
private createUnit(options: CustomMadoriEnvOptions, x: number, z: number) {
function indexToPos(index: number): [number, number] {
const z = Math.floor(index / options.dimension[0]);
const x = index % options.dimension[0];
return [x, z];
}
function posToIndex(x: number, z: number): number {
if (x < 0 || z < 0 || x >= options.dimension[0] || z >= options.dimension[1]) return -1;
return x + (options.dimension[0] * z);
}
const unitDef = options.units[posToIndex(x, z)];
if (unitDef == null) return;
const unitZPositiveDef = options.units[posToIndex(x, z + 1)];
const unitZNegativeDef = options.units[posToIndex(x, z - 1)];
const unitXPositiveDef = options.units[posToIndex(x + 1, z)];
const unitXNegativeDef = options.units[posToIndex(x - 1, z)];
const shiftedX = x - (options.dimension[0] / 2) + 0.5;
const unitRoot = new BABYLON.TransformNode(`unit_${x}_${z}`, this.engine.scene);
unitRoot.parent = this.rootNode;
unitRoot.position = new BABYLON.Vector3(cm(100) * shiftedX, 0, cm(100) * z);
const defaultFlooringMaterial = this.floorMaterials[options.flooringMaterials[0].id];
const unitFloorRootNode = this.floorRootNode.clone(`unit_${x}_${z}_floor`, unitRoot)!;
unitFloorRootNode.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
const flooringMesh = unitFloorRootNode.getChildMeshes().find(m => m.name.includes('__FLOOR__'));
flooringMesh.material = unitDef.flooring?.material != null && this.floorMaterials[unitDef.flooring.material] != null ? this.floorMaterials[unitDef.flooring.material] : defaultFlooringMaterial;
const defaultCeilingMaterial = this.ceilingMaterials[options.ceilingMaterials[0].id];
const ceilingMesh = unitFloorRootNode.getChildMeshes().find(m => m.name.includes('__CEILING__'));
ceilingMesh.material = unitDef.ceiling?.material != null && this.ceilingMaterials[unitDef.ceiling.material] != null ? this.ceilingMaterials[unitDef.ceiling.material] : defaultCeilingMaterial;
const defaultWallMaterial = this.wallMaterials[options.wallMaterials[0].id];
const createWall = (dir: 'zPositive' | 'zNegative' | 'xPositive' | 'xNegative') => {
const wallDef = unitDef.walls?.[dir] ?? {};
const wallRootNode = this.wallRootNode.clone(`unit_${x}_${z}_wall_${dir}`, unitRoot)!;
wallRootNode.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
switch (dir) {
case 'zPositive':
wallRootNode.rotation = new BABYLON.Vector3(0, Math.PI, 0);
wallRootNode.position = new BABYLON.Vector3(0, 0, cm(50));
break;
case 'zNegative':
wallRootNode.position = new BABYLON.Vector3(0, 0, cm(-50));
break;
case 'xPositive':
wallRootNode.rotation = new BABYLON.Vector3(0, -Math.PI / 2, 0);
wallRootNode.position = new BABYLON.Vector3(cm(50), 0, 0);
break;
case 'xNegative':
wallRootNode.rotation = new BABYLON.Vector3(0, Math.PI / 2, 0);
wallRootNode.position = new BABYLON.Vector3(cm(-50), 0, 0);
break;
}
const beamMesh = wallRootNode.getChildMeshes().find(m => m.name.includes('__BEAM__'));
beamMesh.isVisible = wallDef.withBeam === true;
const baseboardMesh = wallRootNode.getChildMeshes().find(m => m.name.includes('__BASEBOARD__'));
baseboardMesh.isVisible = wallDef.withBaseboard === true;
switch (wallDef.type) {
case 'window': {
const wallNode = this.wallBRootNode.clone('', wallRootNode)!;
const wallMesh = wallNode.getChildMeshes().find(m => m.name.includes('__WALL__'))!;
wallMesh.material = wallDef.material != null && this.wallMaterials[wallDef.material] != null ? this.wallMaterials[wallDef.material] : defaultWallMaterial;
break;
}
case 'door': {
//wallMeshOriginal = this.wallAMesh;
break;
}
default: {
const wallNode = this.wallARootNode.clone('', wallRootNode)!;
const wallMesh = wallNode.getChildMeshes().find(m => m.name.includes('__WALL__'))!;
wallMesh.material = wallDef.material != null && this.wallMaterials[wallDef.material] != null ? this.wallMaterials[wallDef.material] : defaultWallMaterial;
break;
}
}
};
if (unitZPositiveDef == null) createWall('zPositive');
if (unitZNegativeDef == null) createWall('zNegative');
if (unitXPositiveDef == null) createWall('xPositive');
if (unitXNegativeDef == null) createWall('xNegative');
for (const mesh of unitRoot.getChildMeshes()) {
this.meshes.push(mesh);
}
this.registerMeshes(unitRoot.getChildMeshes());
return unitRoot;
}
public applyDayPeriod(dayPeriod: number) {
if (this.skyboxMat == null) return;
if (dayPeriod === 0) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.7, 0.9, 1.0);
} else if (dayPeriod === 1) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.8, 0.5, 0.3);
} else {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.05, 0.05, 0.2);
}
if (this.sunLight != null) {
this.sunLight.diffuse = dayPeriod === 0 ? new BABYLON.Color3(1.0, 0.9, 0.8) : dayPeriod === 1 ? new BABYLON.Color3(1.0, 0.8, 0.6) : new BABYLON.Color3(0.6, 0.8, 1.0);
this.sunLight.intensity = dayPeriod === 0 ? 3 : dayPeriod === 1 ? 1 : 0.25;
}
}
public applyRoomLight(): void {
if (this.roomLight == null) return;
this.roomLight.diffuse = new BABYLON.Color3(...this.engine.roomState.light.color);
this.roomLight.intensity = 0.0005 * WORLD_SCALE * WORLD_SCALE * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0);
if (this.envMapIndoor != null) this.envMapIndoor.level = 0.025 + (0.575 * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0));
for (const m of this.engine.scene.materials) {
if (m.metadata?.disableEnvMap) {
m.ambientColor = this.isRoomLightOn ? new BABYLON.Color3(0.5, 0.5, 0.5) : new BABYLON.Color3(0.025, 0.025, 0.025);
}
}
}
public applyOptions(options: CustomMadoriEnvOptions) {
// TODO: 返り値をpromiseにしてちゃんとテクスチャが読み終わってからresolveする
for (const n of this.unitRootNodes) {
if (n != null) n.dispose();
}
this.unitRootNodes = [];
for (let z = 0; z < options.dimension[1]; z++) {
for (let x = 0; x < options.dimension[0]; x++) {
const node = this.createUnit(options, x, z);
this.unitRootNodes.push(node);
}
}
}
public dispose() {
for (const m of this.meshes) {
m.dispose(false, true);
}
this.skybox?.dispose();
this.skyboxMat?.dispose();
this.envMapIndoor?.dispose();
this.roomLight?.dispose();
this.sunLight?.dispose();
if (this.loaderResult != null) {
for (const m of this.loaderResult.meshes) {
m.dispose(false, true);
}
for (const t of this.loaderResult.transformNodes) {
t.dispose(false, true);
}
}
super.dispose();
}
}
@@ -1,160 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { findMaterial, GRAPHICS_QUALITY } from '../../utility.js';
import { SYSTEM_HEYA_MESH_NAMES } from '../utility.js';
import { RoomEnvManager } from '../env.js';
import type { RoomEngine } from '../engine.js';
import type { JapaneseEnvOptions } from 'misskey-world/src/room/env.js';
export class JapaneseEnvManager extends RoomEnvManager<JapaneseEnvOptions> {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private meshes: BABYLON.Mesh[] = [];
private skybox: BABYLON.Mesh | null = null;
private skyboxMat: BABYLON.StandardMaterial | null = null;
private roomLight: BABYLON.SpotLight | null = null;
private sunLight: BABYLON.DirectionalLight | null = null;
public envMapIndoor: BABYLON.CubeTexture | null = null;
public maxCameraZ = cm(1000);
constructor(engine: RoomEngine) {
super(engine);
}
public async load(options: JapaneseEnvOptions) {
this.skybox = BABYLON.MeshBuilder.CreateBox('skybox', { size: cm(1000) }, this.engine.scene);
this.skyboxMat = new BABYLON.StandardMaterial('skyboxMat', this.engine.scene);
this.skyboxMat.backFaceCulling = false;
this.skyboxMat.disableLighting = true;
this.skybox.material = this.skyboxMat;
this.skybox.infiniteDistance = true;
this.roomLight = new BABYLON.SpotLight('env:RoomLight', new BABYLON.Vector3(0, cm(249), 0), new BABYLON.Vector3(0, -1, 0), 16, 8, this.engine.scene);
this.roomLight.shadowMinZ = cm(10);
this.roomLight.shadowMaxZ = cm(300);
this.roomLight.radius = cm(30);
this.applyRoomLight();
if (this.engine.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM) {
const shadowGeneratorForRoomLight = new BABYLON.ShadowGenerator(this.engine.graphicsQuality <= GRAPHICS_QUALITY.MEDIUM ? 1024 : 2048, this.roomLight);
shadowGeneratorForRoomLight.forceBackFacesOnly = true;
shadowGeneratorForRoomLight.bias = 0.0005;
shadowGeneratorForRoomLight.usePercentageCloserFiltering = true;
shadowGeneratorForRoomLight.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
//shadowGeneratorForRoomLight.useContactHardeningShadow = true;
//shadowGeneratorForRoomLight.contactHardeningLightSizeUVRatio = 0.01;
this.registerShadowGenerator(shadowGeneratorForRoomLight);
}
if (this.engine.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM) {
this.sunLight = new BABYLON.DirectionalLight('env:SunLight', new BABYLON.Vector3(0.2, -1, -1), this.engine.scene);
this.sunLight.position = new BABYLON.Vector3(cm(-20), cm(1000), cm(1000));
this.sunLight.shadowMinZ = cm(1000);
this.sunLight.shadowMaxZ = cm(2000);
const shadowGeneratorForSunLight = new BABYLON.ShadowGenerator(this.engine.graphicsQuality <= GRAPHICS_QUALITY.MEDIUM ? 1024 : 2048, this.sunLight);
shadowGeneratorForSunLight.forceBackFacesOnly = true;
shadowGeneratorForSunLight.bias = 0.00001;
shadowGeneratorForSunLight.usePercentageCloserFiltering = true;
shadowGeneratorForSunLight.usePoissonSampling = true;
this.registerShadowGenerator(shadowGeneratorForSunLight);
}
this.loaderResult = await BABYLON.ImportMeshAsync('/client-assets/room/envs/japanese/japanese.glb', this.engine.scene);
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.engine.scene);
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(500), cm(500), cm(500));
this.meshes = this.loaderResult.meshes.filter(m => m instanceof BABYLON.Mesh);
this.meshes[0].scaling = this.meshes[0].scaling.scale(WORLD_SCALE);
this.meshes[0].rotationQuaternion = null;
this.meshes[0].rotation = new BABYLON.Vector3(0, 0, 0);
// instanced mesh を通常の mesh に変換 (そうしないとマテリアルが共有される)
for (const mesh of this.loaderResult.meshes) {
if (mesh instanceof BABYLON.InstancedMesh) {
const realizedMesh = mesh.sourceMesh.clone(mesh.name, null, true);
realizedMesh.position = mesh.position.clone();
if (mesh.rotationQuaternion) {
realizedMesh.rotationQuaternion = mesh.rotationQuaternion.clone();
} else {
realizedMesh.rotation = mesh.rotation.clone();
}
realizedMesh.scaling = mesh.scaling.clone();
realizedMesh.parent = mesh.parent;
mesh.dispose();
this.engine.scene.removeMesh(mesh);
this.meshes.push(realizedMesh);
}
}
for (const mesh of this.meshes) {
if (SYSTEM_HEYA_MESH_NAMES.some(name => mesh.name.includes(name))) continue;
mesh.receiveShadows = true;
this.addShadowCaster(mesh);
}
await this.applyOptions(options);
}
public applyDayPeriod(dayPeriod: number) {
if (this.skyboxMat == null) return;
if (dayPeriod === 0) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.7, 0.9, 1.0);
} else if (dayPeriod === 1) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.8, 0.5, 0.3);
} else {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.05, 0.05, 0.2);
}
if (this.sunLight != null) {
this.sunLight.diffuse = dayPeriod === 0 ? new BABYLON.Color3(1.0, 0.9, 0.8) : dayPeriod === 1 ? new BABYLON.Color3(1.0, 0.8, 0.6) : new BABYLON.Color3(0.6, 0.8, 1.0);
this.sunLight.intensity = dayPeriod === 0 ? 3 : dayPeriod === 1 ? 1 : 0.25;
}
}
public applyRoomLight(): void {
if (this.roomLight == null) return;
this.roomLight.diffuse = new BABYLON.Color3(...this.engine.roomState.light.color);
this.roomLight.intensity = 18 * WORLD_SCALE * WORLD_SCALE * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0);
if (this.envMapIndoor != null) this.envMapIndoor.level = 0.025 + (0.575 * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0));
for (const m of this.engine.scene.materials) {
if (m.metadata?.disableEnvMap) {
m.ambientColor = this.isRoomLightOn ? new BABYLON.Color3(0.5, 0.5, 0.5) : new BABYLON.Color3(0.025, 0.025, 0.025);
}
}
}
public applyOptions(options: JapaneseEnvOptions) {
this.registerMeshes(this.meshes);
}
public dispose() {
for (const m of this.meshes) {
m.dispose(false, true);
}
this.skybox?.dispose();
this.skyboxMat?.dispose();
this.envMapIndoor?.dispose();
this.roomLight?.dispose();
this.sunLight?.dispose();
if (this.loaderResult != null) {
for (const m of this.loaderResult.meshes) {
m.dispose(false, true);
}
for (const t of this.loaderResult.transformNodes) {
t.dispose(false, true);
}
}
super.dispose();
}
}
@@ -1,136 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { findMaterial, GRAPHICS_QUALITY } from '../../utility.js';
import { SYSTEM_HEYA_MESH_NAMES } from '../utility.js';
import { RoomEnvManager } from '../env.js';
import type { RoomEngine } from '../engine.js';
import type { MuseumEnvOptions } from 'misskey-world/src/room/env.js';
export class MuseumEnvManager extends RoomEnvManager<MuseumEnvOptions> {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private meshes: BABYLON.Mesh[] = [];
private roomLight: BABYLON.DirectionalLight | null = null;
private subRoomLights: BABYLON.SpotLight[] = [];
public envMapIndoor: BABYLON.CubeTexture | null = null;
public maxCameraZ = cm(3000);
constructor(engine: RoomEngine) {
super(engine);
}
public async load(options: MuseumEnvOptions) {
this.loaderResult = await BABYLON.ImportMeshAsync('/client-assets/room/envs/museum/museum.glb', this.engine.scene);
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.engine.scene);
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(2000), cm(500), cm(2000));
this.meshes = this.loaderResult.meshes.filter(m => m instanceof BABYLON.Mesh);
this.meshes[0].scaling = this.meshes[0].scaling.scale(WORLD_SCALE);
this.meshes[0].rotationQuaternion = null;
this.meshes[0].rotation = new BABYLON.Vector3(0, 0, 0);
// instanced mesh を通常の mesh に変換 (そうしないとマテリアルが共有される)
for (const mesh of this.loaderResult.meshes) {
if (mesh instanceof BABYLON.InstancedMesh) {
const realizedMesh = mesh.sourceMesh.clone(mesh.name, null, true);
realizedMesh.position = mesh.position.clone();
if (mesh.rotationQuaternion) {
realizedMesh.rotationQuaternion = mesh.rotationQuaternion.clone();
} else {
realizedMesh.rotation = mesh.rotation.clone();
}
realizedMesh.scaling = mesh.scaling.clone();
realizedMesh.parent = mesh.parent;
mesh.dispose();
this.engine.scene.removeMesh(mesh);
this.meshes.push(realizedMesh);
}
}
this.roomLight = new BABYLON.DirectionalLight('env:RoomLight', new BABYLON.Vector3(0, -1, 0), this.engine.scene);
this.roomLight.position = new BABYLON.Vector3(0, cm(300), 0);
this.roomLight.shadowMinZ = cm(10);
this.roomLight.shadowMaxZ = cm(500);
this.roomLight.radius = cm(30);
if (this.engine.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM) {
const shadowGeneratorForRoomLight = new BABYLON.ShadowGenerator(this.engine.graphicsQuality <= GRAPHICS_QUALITY.MEDIUM ? 1024 : 2048, this.roomLight);
shadowGeneratorForRoomLight.forceBackFacesOnly = true;
shadowGeneratorForRoomLight.bias = 0.00001;
shadowGeneratorForRoomLight.normalBias = 0.005;
shadowGeneratorForRoomLight.usePercentageCloserFiltering = true;
shadowGeneratorForRoomLight.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
//this.shadowGeneratorForRoomLight.useContactHardeningShadow = true;
this.registerShadowGenerator(shadowGeneratorForRoomLight);
}
for (const node of this.meshes.filter(mesh => mesh.name.includes('__LIGHT__'))) {
const light = new BABYLON.SpotLight('env:SubRoomLight', node.position, new BABYLON.Vector3(0, -1, 0), 16, 8, this.engine.scene, true);
light.range = cm(500);
light.radius = cm(15);
light.parent = this.meshes[0];
this.engine.lightContainer.addLight(light);
this.subRoomLights.push(light);
}
this.applyRoomLight();
for (const mesh of this.meshes) {
if (SYSTEM_HEYA_MESH_NAMES.some(name => mesh.name.includes(name))) continue;
mesh.receiveShadows = true;
//this.addShadowCaster(mesh);
}
await this.applyOptions(options);
}
public applyDayPeriod(dayPeriod: number) {
}
public applyRoomLight(): void {
if (this.roomLight == null) return;
this.roomLight.diffuse = new BABYLON.Color3(...this.engine.roomState.light.color);
this.roomLight.intensity = 0.00005 * WORLD_SCALE * WORLD_SCALE * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0);
for (const subLight of this.subRoomLights) {
subLight.diffuse = new BABYLON.Color3(...this.engine.roomState.light.color);
subLight.intensity = 20 * WORLD_SCALE * WORLD_SCALE * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0);
}
if (this.envMapIndoor != null) this.envMapIndoor.level = 0.025 + (0.175 * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0));
for (const m of this.engine.scene.materials) {
if (m.metadata?.disableEnvMap) {
m.ambientColor = this.isRoomLightOn ? new BABYLON.Color3(0.5, 0.5, 0.5) : new BABYLON.Color3(0.025, 0.025, 0.025);
}
}
}
public applyOptions(options: MuseumEnvOptions) {
this.registerMeshes(this.meshes);
}
public dispose() {
this.envMapIndoor?.dispose();
this.roomLight?.dispose();
for (const subLight of this.subRoomLights) {
subLight.dispose();
}
if (this.loaderResult != null) {
for (const m of this.loaderResult.meshes) {
m.dispose(false, true);
}
for (const t of this.loaderResult.transformNodes) {
t.dispose(false, true);
}
}
for (const m of this.meshes) {
m.dispose(false, true);
}
super.dispose();
}
}
@@ -1,474 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as BABYLON from '@babylonjs/core/pure.js';
import { cm, WORLD_SCALE } from 'misskey-world/src/utility.js';
import { findMaterial, GRAPHICS_QUALITY, treeClone } from '../../utility.js';
import { SYSTEM_HEYA_MESH_NAMES } from '../utility.js';
import { RoomEnvManager } from '../env.js';
import type { RoomEngine } from '../engine.js';
import type { SimpleEnvOptions } from 'misskey-world/src/room/env.js';
// TODO: マテリアルは必要になるまで作成しないようにする
export class SimpleEnvManager extends RoomEnvManager<SimpleEnvOptions> {
private loaderResult: BABYLON.ISceneLoaderAsyncResult | null = null;
private rootNode: BABYLON.TransformNode;
private wallRoots: Record<'zPositive' | 'zNegative' | 'xPositive' | 'xNegative', BABYLON.TransformNode>;
private wallScalingContainers: Record<'zPositive' | 'zNegative' | 'xPositive' | 'xNegative', BABYLON.TransformNode>;
private wallMaterials: Record<'zPositive' | 'zNegative' | 'xPositive' | 'xNegative', BABYLON.PBRMaterial>;
private wallBeamMaterials: Record<'zPositive' | 'zNegative' | 'xPositive' | 'xNegative', BABYLON.PBRMaterial>;
private pillarRoots: Record<'zp_xp' | 'zp_xn' | 'zn_xp' | 'zn_xn', BABYLON.TransformNode>;
private pillarScalingContainers: Record<'zp_xp' | 'zp_xn' | 'zn_xp' | 'zn_xn', BABYLON.TransformNode>;
private pillarMaterials: Record<'zp_xp' | 'zp_xn' | 'zn_xp' | 'zn_xn', BABYLON.PBRMaterial>;
private ceilingMaterial: BABYLON.PBRMaterial;
private floorMaterial: BABYLON.PBRMaterial;
private skybox: BABYLON.Mesh | null = null;
private skyboxMat: BABYLON.StandardMaterial | null = null;
private roomLight: BABYLON.SpotLight | null = null;
private sunLight: BABYLON.DirectionalLight | null = null;
public envMapIndoor: BABYLON.CubeTexture | null = null;
public maxCameraZ = cm(1000);
constructor(engine: RoomEngine) {
super(engine);
this.rootNode = new BABYLON.TransformNode('simpleEnvRoot', this.engine.scene);
this.wallRoots = {
zPositive: new BABYLON.TransformNode('wallRootZPositive', this.engine.scene),
zNegative: new BABYLON.TransformNode('wallRootZNegative', this.engine.scene),
xPositive: new BABYLON.TransformNode('wallRootXPositive', this.engine.scene),
xNegative: new BABYLON.TransformNode('wallRootXNegative', this.engine.scene),
};
this.wallRoots.zPositive.parent = this.rootNode;
this.wallRoots.zPositive.position.z = cm(150);
this.wallRoots.zPositive.rotation.y = Math.PI;
this.wallRoots.zNegative.parent = this.rootNode;
this.wallRoots.zNegative.position.z = -cm(150);
this.wallRoots.xPositive.parent = this.rootNode;
this.wallRoots.xPositive.position.x = cm(150);
this.wallRoots.xPositive.rotation.y = -Math.PI / 2;
this.wallRoots.xNegative.parent = this.rootNode;
this.wallRoots.xNegative.position.x = -cm(150);
this.wallRoots.xNegative.rotation.y = Math.PI / 2;
this.wallScalingContainers = {
zPositive: new BABYLON.TransformNode('wallScalingContainerZPositive', this.engine.scene),
zNegative: new BABYLON.TransformNode('wallScalingContainerZNegative', this.engine.scene),
xPositive: new BABYLON.TransformNode('wallScalingContainerXPositive', this.engine.scene),
xNegative: new BABYLON.TransformNode('wallScalingContainerXNegative', this.engine.scene),
};
for (const [k, v] of Object.entries(this.wallScalingContainers)) {
v.parent = this.wallRoots[k as keyof typeof this.wallRoots];
v.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
}
this.pillarRoots = {
zp_xp: new BABYLON.TransformNode('pillarRootZpXp', this.engine.scene),
zp_xn: new BABYLON.TransformNode('pillarRootZpXn', this.engine.scene),
zn_xp: new BABYLON.TransformNode('pillarRootZnXp', this.engine.scene),
zn_xn: new BABYLON.TransformNode('pillarRootZnXn', this.engine.scene),
};
this.pillarRoots.zp_xp.parent = this.rootNode;
this.pillarRoots.zp_xp.position = new BABYLON.Vector3(cm(150), 0, cm(150));
this.pillarRoots.zp_xp.rotation.y = -Math.PI / 2;
this.pillarRoots.zp_xn.parent = this.rootNode;
this.pillarRoots.zp_xn.position = new BABYLON.Vector3(-cm(150), 0, cm(150));
this.pillarRoots.zp_xn.rotation.y = Math.PI;
this.pillarRoots.zn_xp.parent = this.rootNode;
this.pillarRoots.zn_xp.position = new BABYLON.Vector3(cm(150), 0, -cm(150));
this.pillarRoots.zn_xp.rotation.y = 0;
this.pillarRoots.zn_xn.parent = this.rootNode;
this.pillarRoots.zn_xn.position = new BABYLON.Vector3(-cm(150), 0, -cm(150));
this.pillarRoots.zn_xn.rotation.y = Math.PI / 2;
this.pillarScalingContainers = {
zp_xp: new BABYLON.TransformNode('pillarScalingContainerZpXp', this.engine.scene),
zp_xn: new BABYLON.TransformNode('pillarScalingContainerZpXn', this.engine.scene),
zn_xp: new BABYLON.TransformNode('pillarScalingContainerZnXp', this.engine.scene),
zn_xn: new BABYLON.TransformNode('pillarScalingContainerZnXn', this.engine.scene),
};
for (const [k, v] of Object.entries(this.pillarScalingContainers)) {
v.parent = this.pillarRoots[k as keyof typeof this.pillarRoots];
v.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
}
const wallMaterial = new BABYLON.PBRMaterial('wallMaterial', this.engine.scene);
wallMaterial.albedoColor = new BABYLON.Color3(0.8, 0.8, 0.8);
wallMaterial.roughness = 0.7;
wallMaterial.metallic = 0;
this.wallMaterials = {
zPositive: wallMaterial.clone('wallZPositiveMaterial'),
zNegative: wallMaterial.clone('wallZNegativeMaterial'),
xPositive: wallMaterial.clone('wallXPositiveMaterial'),
xNegative: wallMaterial.clone('wallXNegativeMaterial'),
};
const beamMaterial = wallMaterial.clone('beamMaterial');
this.wallBeamMaterials = {
zPositive: beamMaterial.clone('beamZPositiveMaterial'),
zNegative: beamMaterial.clone('beamZNegativeMaterial'),
xPositive: beamMaterial.clone('beamXPositiveMaterial'),
xNegative: beamMaterial.clone('beamXNegativeMaterial'),
};
const pillarMaterial = wallMaterial.clone('pillarMaterial');
this.pillarMaterials = {
zp_xp: pillarMaterial.clone('pillarMaterialZpXp'),
zp_xn: pillarMaterial.clone('pillarMaterialZpXn'),
zn_xp: pillarMaterial.clone('pillarMaterialZnXp'),
zn_xn: pillarMaterial.clone('pillarMaterialZnXn'),
};
this.ceilingMaterial = new BABYLON.PBRMaterial('ceilingMaterial', this.engine.scene);
this.ceilingMaterial.albedoColor = new BABYLON.Color3(0.8, 0.8, 0.8);
this.ceilingMaterial.roughness = 0.7;
this.ceilingMaterial.metallic = 0;
this.floorMaterial = new BABYLON.PBRMaterial('floorMaterial', this.engine.scene);
this.floorMaterial.albedoColor = new BABYLON.Color3(0.8, 0.8, 0.8);
this.floorMaterial.roughness = 0.7;
this.floorMaterial.metallic = 0;
const baseboardMaterial = new BABYLON.PBRMaterial('baseboardMaterial', this.engine.scene);
baseboardMaterial.albedoColor = new BABYLON.Color3(0.8, 0.8, 0.8);
baseboardMaterial.roughness = 0.7;
baseboardMaterial.metallic = 0;
this.skybox = BABYLON.MeshBuilder.CreateBox('skybox', { size: cm(1000) }, this.engine.scene);
this.skyboxMat = new BABYLON.StandardMaterial('skyboxMat', this.engine.scene);
this.skyboxMat.backFaceCulling = false;
this.skyboxMat.disableLighting = true;
this.skybox.material = this.skyboxMat;
this.skybox.infiniteDistance = true;
this.roomLight = new BABYLON.SpotLight('env:RoomLight', new BABYLON.Vector3(0, cm(249), 0), new BABYLON.Vector3(0, -1, 0), 16, 8, this.engine.scene);
this.roomLight.shadowMinZ = cm(10);
this.roomLight.shadowMaxZ = cm(300);
this.roomLight.radius = cm(30);
this.applyRoomLight();
if (this.engine.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM) {
const shadowGeneratorForRoomLight = new BABYLON.ShadowGenerator(this.engine.graphicsQuality <= GRAPHICS_QUALITY.MEDIUM ? 1024 : 2048, this.roomLight);
shadowGeneratorForRoomLight.forceBackFacesOnly = true;
shadowGeneratorForRoomLight.bias = 0.0005;
shadowGeneratorForRoomLight.usePercentageCloserFiltering = true;
shadowGeneratorForRoomLight.filteringQuality = BABYLON.ShadowGenerator.QUALITY_HIGH;
//shadowGeneratorForRoomLight.useContactHardeningShadow = true;
//shadowGeneratorForRoomLight.contactHardeningLightSizeUVRatio = 0.01;
this.registerShadowGenerator(shadowGeneratorForRoomLight);
}
if (this.engine.graphicsQuality >= GRAPHICS_QUALITY.MEDIUM) {
this.sunLight = new BABYLON.DirectionalLight('env:SunLight', new BABYLON.Vector3(0.2, -1, -1), this.engine.scene);
this.sunLight.position = new BABYLON.Vector3(cm(-20), cm(1000), cm(1000));
this.sunLight.shadowMinZ = cm(1000);
this.sunLight.shadowMaxZ = cm(2000);
const shadowGeneratorForSunLight = new BABYLON.ShadowGenerator(this.engine.graphicsQuality <= GRAPHICS_QUALITY.MEDIUM ? 1024 : 2048, this.sunLight);
shadowGeneratorForSunLight.forceBackFacesOnly = true;
shadowGeneratorForSunLight.bias = 0.00001;
shadowGeneratorForSunLight.usePercentageCloserFiltering = true;
shadowGeneratorForSunLight.usePoissonSampling = true;
this.registerShadowGenerator(shadowGeneratorForSunLight);
}
this.envMapIndoor = BABYLON.CubeTexture.CreateFromPrefilteredData('/client-assets/room/indoor.env', this.engine.scene);
this.envMapIndoor.boundingBoxSize = new BABYLON.Vector3(cm(500), cm(500), cm(500));
}
public async load(options: SimpleEnvOptions) {
this.loaderResult = await BABYLON.LoadAssetContainerAsync('/client-assets/room/envs/simple/300.glb', this.engine.scene);
const collisionScalingContainer = new BABYLON.TransformNode('collisionScalingContainer', this.engine.scene);
collisionScalingContainer.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
collisionScalingContainer.parent = this.rootNode;
const collision = this.loaderResult.meshes.find(m => m.name.includes('__COLLISION__'))!;
collision.parent = collisionScalingContainer;
const lightBlockerScalingContainer = new BABYLON.TransformNode('lightBlockerScalingContainer', this.engine.scene);
lightBlockerScalingContainer.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
lightBlockerScalingContainer.parent = this.rootNode;
const lightBlocker = this.loaderResult.meshes.find(m => m.name.includes('__LIGHT_BLOCKER__'))!;
lightBlocker.parent = lightBlockerScalingContainer;
lightBlocker.rotationQuaternion = null;
lightBlocker.rotation.y = Math.PI;
const originalFloorRoot = this.loaderResult.transformNodes.find(t => t.name.includes('__FLOOR__'))!;
originalFloorRoot.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
originalFloorRoot.parent = this.rootNode;
for (const child of originalFloorRoot.getChildMeshes()) {
if (child.material.name.includes('__FLOOR__')) {
child.material = this.floorMaterial;
}
}
const originalCeilingRoot = this.loaderResult.transformNodes.find(t => t.name.includes('__CEILING__'))!;
originalCeilingRoot.scaling = new BABYLON.Vector3(-WORLD_SCALE, WORLD_SCALE, WORLD_SCALE);
originalCeilingRoot.position.y = cm(250);
originalCeilingRoot.parent = this.rootNode;
for (const child of originalCeilingRoot.getChildMeshes()) {
if (child.material.name.includes('__CEILING__')) {
child.material = this.ceilingMaterial;
}
}
await this.applyOptions(options);
}
public applyOptions(options: SimpleEnvOptions) {
// clean up
for (const type of ['zPositive', 'zNegative', 'xPositive', 'xNegative'] as const) {
const wallRoot = this.wallScalingContainers[type];
for (const mesh of wallRoot.getChildMeshes()) {
mesh.dispose();
this.engine.scene.removeMesh(mesh);
this.removeShadowCaster(mesh);
}
}
for (const type of ['zp_xp', 'zp_xn', 'zn_xp', 'zn_xn'] as const) {
const pillarRoot = this.pillarScalingContainers[type];
for (const mesh of pillarRoot.getChildMeshes()) {
mesh.dispose();
this.engine.scene.removeMesh(mesh);
this.removeShadowCaster(mesh);
}
}
// TODO: 返り値をpromiseにしてちゃんとテクスチャが読み終わってからresolveする
for (const type of ['zPositive', 'zNegative', 'xPositive', 'xNegative'] as const) {
const wallRoot = this.wallScalingContainers[type];
const wallOptions = options.walls[type];
const originalRoot =
type === 'zPositive' ?
options.window === 'kosidakamado' ? this.loaderResult!.transformNodes.find(t => t.name.includes('__WALL_KOSIDAKAMADO__'))! :
options.window === 'demado' ? this.loaderResult!.transformNodes.find(t => t.name.includes('__WALL_DEMADO__'))! :
this.loaderResult!.transformNodes.find(t => t.name.includes('__WALL__'))!
: type === 'zNegative'
? this.loaderResult!.transformNodes.find(t => t.name.includes('__WALL_DOOR__'))!
: this.loaderResult!.transformNodes.find(t => t.name.includes('__WALL__'))!;
for (const child of treeClone(originalRoot).getChildren()) {
child.parent = wallRoot;
}
for (const child of wallRoot.getChildMeshes()) {
if (child.material.name.includes('__WALL__')) {
child.material = this.wallMaterials[type];
} else if (child.material.name.includes('__BEAM__')) {
child.material = this.wallBeamMaterials[type];
}
}
for (const mesh of wallRoot.getChildMeshes()) {
if (mesh.name.includes('__BEAM__')) {
mesh.setEnabled(wallOptions.withBeam);
} else if (mesh.name.includes('__BASEBOARD__')) {
mesh.setEnabled(wallOptions.withBaseboard);
}
}
{
const targetMaterial = this.wallMaterials[type];
targetMaterial.unfreeze();
targetMaterial.albedoColor = new BABYLON.Color3(...wallOptions.color);
const texPath = wallOptions.material === 'wood' ? '/client-assets/room/textures/wall-wood2.png'
: wallOptions.material === 'concrete' ? '/client-assets/room/textures/concrete1.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
tex.wrapU = BABYLON.Texture.WRAP_ADDRESSMODE;
tex.wrapV = BABYLON.Texture.WRAP_ADDRESSMODE;
targetMaterial.albedoTexture = tex;
} else {
targetMaterial.albedoTexture = null;
}
targetMaterial.freeze();
}
{
const targetMaterial = this.wallBeamMaterials[type];
targetMaterial.unfreeze();
targetMaterial.albedoColor = new BABYLON.Color3(...wallOptions.beamColor);
const texPath = wallOptions.beamMaterial === 'wood' ? '/client-assets/room/textures/wall-wood2.png'
: wallOptions.beamMaterial === 'concrete' ? '/client-assets/room/textures/concrete1.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
targetMaterial.albedoTexture = tex;
} else {
targetMaterial.albedoTexture = null;
}
targetMaterial.freeze();
}
}
for (const type of ['zp_xp', 'zp_xn', 'zn_xp', 'zn_xn'] as const) {
const pillarRoot = this.pillarScalingContainers[type];
const pillarOptions = options.pillars[type];
let isEnabled = pillarOptions.show;
if (!isEnabled) {
// 梁同士が直交することは許さない(z-fightingが発生する)ので柱を強制追加
if (type === 'zp_xp') {
isEnabled = options.walls.zPositive.withBeam && options.walls.xPositive.withBeam;
} else if (type === 'zp_xn') {
isEnabled = options.walls.zPositive.withBeam && options.walls.xNegative.withBeam;
} else if (type === 'zn_xp') {
isEnabled = options.walls.zNegative.withBeam && options.walls.xPositive.withBeam;
} else if (type === 'zn_xn') {
isEnabled = options.walls.zNegative.withBeam && options.walls.xNegative.withBeam;
}
}
if (!isEnabled) continue;
const originalRoot = this.loaderResult!.transformNodes.find(t => t.name.includes('__PILLAR__'))!;
for (const child of treeClone(originalRoot).getChildren()) {
child.parent = pillarRoot;
}
for (const child of pillarRoot.getChildMeshes()) {
if (child.material.name.includes('__PILLAR__')) {
child.material = this.pillarMaterials[type];
}
}
{
const targetMaterial = this.pillarMaterials[type];
targetMaterial.unfreeze();
targetMaterial.albedoColor = new BABYLON.Color3(...pillarOptions.color);
const texPath = pillarOptions.material === 'wood' ? '/client-assets/room/textures/wall-wood2.png'
: pillarOptions.material === 'concrete' ? '/client-assets/room/textures/concrete1.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
targetMaterial.albedoTexture = tex;
} else {
targetMaterial.albedoTexture = null;
}
targetMaterial.freeze();
}
}
{
this.ceilingMaterial.unfreeze();
this.ceilingMaterial.albedoColor = new BABYLON.Color3(...options.ceiling.color);
const texPath = options.ceiling.material === 'wood' ? '/client-assets/room/textures/ceiling-wood.png'
: options.ceiling.material === 'concrete' ? '/client-assets/room/textures/concrete3.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
this.ceilingMaterial.albedoTexture = tex;
} else {
this.ceilingMaterial.albedoTexture = null;
}
this.ceilingMaterial.freeze();
}
{
this.floorMaterial.unfreeze();
this.floorMaterial.albedoColor = new BABYLON.Color3(...options.flooring.color);
const texPath = options.flooring.material === 'wood' ? '/client-assets/room/textures/flooring-wood.png'
: options.flooring.material === 'concrete' ? '/client-assets/room/textures/concrete3.png'
: null;
if (texPath != null) {
const tex = new BABYLON.Texture(texPath, this.engine.scene, false, false);
this.floorMaterial.albedoTexture = tex;
} else {
this.floorMaterial.albedoTexture = null;
}
this.floorMaterial.freeze();
}
for (const mesh of this.rootNode.getChildMeshes()) {
if (SYSTEM_HEYA_MESH_NAMES.some(name => mesh.name.includes(name))) continue;
mesh.receiveShadows = true;
//if (mesh.material !== this.floorMaterial) { // 床は他の何にも影を落とさないことが確定している
this.addShadowCaster(mesh);
//}
}
this.registerMeshes(this.rootNode.getChildMeshes());
}
public applyDayPeriod(dayPeriod: number) {
if (this.skyboxMat == null) return;
if (dayPeriod === 0) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.7, 0.9, 1.0);
} else if (dayPeriod === 1) {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.8, 0.5, 0.3);
} else {
this.skyboxMat.emissiveColor = new BABYLON.Color3(0.05, 0.05, 0.2);
}
if (this.sunLight != null) {
this.sunLight.diffuse = dayPeriod === 0 ? new BABYLON.Color3(1.0, 0.9, 0.8) : dayPeriod === 1 ? new BABYLON.Color3(1.0, 0.8, 0.6) : new BABYLON.Color3(0.6, 0.8, 1.0);
this.sunLight.intensity = dayPeriod === 0 ? 3 : dayPeriod === 1 ? 1 : 0.25;
}
}
public applyRoomLight(): void {
if (this.roomLight == null) return;
this.roomLight.diffuse = new BABYLON.Color3(...this.engine.roomState.light.color);
this.roomLight.intensity = 18 * WORLD_SCALE * WORLD_SCALE * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0);
if (this.envMapIndoor != null) this.envMapIndoor.level = 0.025 + (0.575 * this.engine.roomState.light.brightness * (this.isRoomLightOn ? 1 : 0));
for (const m of this.engine.scene.materials) {
if (m.metadata?.disableEnvMap) {
m.ambientColor = this.isRoomLightOn ? new BABYLON.Color3(0.5, 0.5, 0.5) : new BABYLON.Color3(0.025, 0.025, 0.025);
}
}
}
public dispose() {
for (const m of this.rootNode.getChildMeshes()) {
m.dispose(false, true);
}
for (const m of Object.values(this.wallMaterials ?? {})) {
m.dispose();
}
for (const m of Object.values(this.wallBeamMaterials ?? {})) {
m.dispose();
}
for (const m of Object.values(this.pillarMaterials ?? {})) {
m.dispose();
}
this.skybox?.dispose();
this.skyboxMat?.dispose();
this.envMapIndoor?.dispose();
this.roomLight?.dispose();
this.sunLight?.dispose();
if (this.loaderResult != null) {
for (const m of this.loaderResult.meshes) {
m.dispose(false, true);
}
for (const t of this.loaderResult.transformNodes) {
t.dispose(false, true);
}
}
super.dispose();
}
}
@@ -1,247 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { a4Case } from './furnitures/a4Case.js';
import { aircon } from './furnitures/aircon.js';
import { allInOnePc } from './furnitures/allInOnePc.js';
import { aquarium } from './furnitures/aquarium.js';
import { aromaReedDiffuser } from './furnitures/aromaReedDiffuser.js';
import { banknote } from './furnitures/banknote.js';
import { beamLamp } from './furnitures/beamLamp.js';
import { bed } from './furnitures/bed.js';
import { blind } from './furnitures/blind.js';
import { book } from './furnitures/book.js';
import { books } from './furnitures/books.js';
import { boxWallShelf } from './furnitures/boxWallShelf.js';
import { cactusS } from './furnitures/cactusS.js';
import { cardboardBox } from './furnitures/cardboardBox.js';
import { ceilingFanLight } from './furnitures/ceilingFanLight.js';
import { chair } from './furnitures/chair.js';
import { clippedPicture } from './furnitures/clippedPicture.js';
import { coffeeCup } from './furnitures/coffeeCup.js';
import { colorBox } from './furnitures/colorBox.js';
import { cuboid } from './furnitures/cuboid.js';
import { cupNoodle } from './furnitures/cupNoodle.js';
import { curtain } from './furnitures/curtain.js';
import { custardPudding } from './furnitures/custardPudding.js';
import { debugHipoly } from './furnitures/debugHipoly.js';
import { debugMetal } from './furnitures/debugMetal.js';
import { descriptionPlate } from './furnitures/descriptionPlate.js';
import { desk } from './furnitures/desk.js';
import { desktopPc } from './furnitures/desktopPc.js';
import { djMixer } from './furnitures/djMixer.js';
import { djPlayer } from './furnitures/djPlayer.js';
import { ductRailSpotLights } from './furnitures/ductRailSpotLights.js';
import { ductTape } from './furnitures/ductTape.js';
import { electronicDisplayBoard } from './furnitures/electronicDisplayBoard.js';
import { emptyBento } from './furnitures/emptyBento.js';
import { energyDrink } from './furnitures/energyDrink.js';
import { envelope } from './furnitures/envelope.js';
import { facialTissue } from './furnitures/facialTissue.js';
import { glassCylinderPotPlant } from './furnitures/glassCylinderPotPlant.js';
import { handheldGameConsole } from './furnitures/handheldGameConsole.js';
import { hangingDuctRail } from './furnitures/hangingDuctRail.js';
import { hangingTShirt } from './furnitures/hangingTShirt.js';
import { icosahedron } from './furnitures/icosahedron.js';
import { ironFrameShelf } from './furnitures/ironFrameShelf.js';
import { ironFrameTable } from './furnitures/ironFrameTable.js';
import { issyoubin } from './furnitures/issyoubin.js';
import { keyboard } from './furnitures/keyboard.js';
import { laptopPc } from './furnitures/laptopPc.js';
import { largeMousepad } from './furnitures/largeMousepad.js';
import { lavaLamp } from './furnitures/lavaLamp.js';
import { letterCase } from './furnitures/letterCase.js';
import { lowPartitionBar } from './furnitures/lowPartitionBar.js';
import { miObjet } from './furnitures/miObjet.js';
import { milk } from './furnitures/milk.js';
import { miPlate } from './furnitures/miPlate.js';
import { miPlateDisplayed } from './furnitures/miPlateDisplayed.js';
import { mixer } from './furnitures/mixer.js';
import { monitor } from './furnitures/monitor.js';
import { monitorSpeaker } from './furnitures/monitorSpeaker.js';
import { monstera } from './furnitures/monstera.js';
import { mug } from './furnitures/mug.js';
import { newtonsCradle } from './furnitures/newtonsCradle.js';
import { openedCardboardBox } from './furnitures/openedCardboardBox.js';
import { pachira } from './furnitures/pachira.js';
import { petBottle } from './furnitures/petBottle.js';
import { piano } from './furnitures/piano.js';
import { pictureFrame } from './furnitures/pictureFrame.js';
import { pizza } from './furnitures/pizza.js';
import { plant } from './furnitures/plant.js';
import { plant2 } from './furnitures/plant2.js';
import { poster } from './furnitures/poster.js';
import { powerStrip } from './furnitures/powerStrip.js';
import { radiometer } from './furnitures/radiometer.js';
import { randomBooks } from './furnitures/randomBooks.js';
import { recordPlayer } from './furnitures/recordPlayer.js';
import { rolledUpPoster } from './furnitures/rolledUpPoster.js';
import { roundRug } from './furnitures/roundRug.js';
import { router } from './furnitures/router.js';
import { siphon } from './furnitures/siphon.js';
import { snakeplant } from './furnitures/snakeplant.js';
import { sofa } from './furnitures/sofa.js';
import { speaker } from './furnitures/speaker.js';
import { speakerStand } from './furnitures/speakerStand.js';
import { spotLight } from './furnitures/spotLight.js';
import { sprayer } from './furnitures/sprayer.js';
import { stanchionPole } from './furnitures/stanchionPole.js';
import { steelRack } from './furnitures/steelRack.js';
import { stormGlass } from './furnitures/stormGlass.js';
import { tableSalt } from './furnitures/tableSalt.js';
import { tabletopCalendar } from './furnitures/tabletopCalendar.js';
import { tabletopDigitalClock } from './furnitures/tabletopDigitalClock.js';
import { tabletopFlag } from './furnitures/tabletopFlag.js';
import { tabletopGlassPictureFrame } from './furnitures/tabletopGlassPictureFrame.js';
import { tabletopIronFrameStand } from './furnitures/tabletopIronFrameStand.js';
import { tabletopLcdButtonsController } from './furnitures/tabletopLcdButtonsController.js';
import { tabletopPictureFrame } from './furnitures/tabletopPictureFrame.js';
import { tapestry } from './furnitures/tapestry.js';
import { tetrapod } from './furnitures/tetrapod.js';
import { tv } from './furnitures/tv.js';
import { twistedCubeObjet } from './furnitures/twistedCubeObjet.js';
import { usedTissue } from './furnitures/usedTissue.js';
import { wallCanvas } from './furnitures/wallCanvas.js';
import { wallClock } from './furnitures/wallClock.js';
import { wallGlassPictureFrame } from './furnitures/wallGlassPictureFrame.js';
import { wallMirror } from './furnitures/wallMirror.js';
import { wallMountSpotLight } from './furnitures/wallMountSpotLight.js';
import { wallShelf } from './furnitures/wallShelf.js';
import { wireBasket } from './furnitures/wireBasket.js';
import { wireNet } from './furnitures/wireNet.js';
import { woodRingFloorLamp } from './furnitures/woodRingFloorLamp.js';
import { woodRingsPendantLight } from './furnitures/woodRingsPendantLight.js';
import { woodSoundAbsorbingPanel } from './furnitures/woodSoundAbsorbingPanel.js';
import { haniwa } from './furnitures/haniwa.js';
import { ceilingFan } from './furnitures/ceilingFan.js';
import { downlight } from './furnitures/downlight.js';
import { kakejiku } from './furnitures/kakejiku.js';
import { herbarium } from './furnitures/herbarium.js';
import type { FurnitureDef } from './furniture.js';
export const FURNITURE_DEFS = [
a4Case,
aircon,
allInOnePc,
aquarium,
aromaReedDiffuser,
banknote,
beamLamp,
bed,
blind,
books,
boxWallShelf,
cactusS,
cardboardBox,
ceilingFanLight,
ceilingFan,
chair,
coffeeCup,
colorBox,
cuboid,
cupNoodle,
custardPudding,
desk,
desktopPc,
djMixer,
djPlayer,
ductRailSpotLights,
ductTape,
electronicDisplayBoard,
emptyBento,
energyDrink,
envelope,
facialTissue,
glassCylinderPotPlant,
hangingTShirt,
icosahedron,
ironFrameShelf,
ironFrameTable,
issyoubin,
keyboard,
laptopPc,
largeMousepad,
lavaLamp,
letterCase,
miObjet,
milk,
miPlate,
miPlateDisplayed,
mixer,
monitor,
monitorSpeaker,
monstera,
mug,
newtonsCradle,
openedCardboardBox,
pachira,
petBottle,
piano,
pictureFrame,
pizza,
plant,
plant2,
poster,
powerStrip,
radiometer,
randomBooks,
recordPlayer,
rolledUpPoster,
roundRug,
router,
siphon,
snakeplant,
sofa,
speaker,
speakerStand,
sprayer,
steelRack,
stormGlass,
tableSalt,
tabletopCalendar,
tabletopDigitalClock,
tabletopFlag,
tabletopGlassPictureFrame,
tabletopIronFrameStand,
tabletopPictureFrame,
tabletopLcdButtonsController,
tapestry,
tetrapod,
tv,
twistedCubeObjet,
usedTissue,
wallCanvas,
wallClock,
wallGlassPictureFrame,
wallMirror,
wallMountSpotLight,
wallShelf,
woodRingFloorLamp,
woodRingsPendantLight,
woodSoundAbsorbingPanel,
hangingDuctRail,
spotLight,
lowPartitionBar,
descriptionPlate,
stanchionPole,
handheldGameConsole,
debugMetal,
curtain,
wireNet,
clippedPicture,
wireBasket,
haniwa,
downlight,
kakejiku,
herbarium,
] as FurnitureDef[];
export function getFurnitureDef(type: string): FurnitureDef {
const def = FURNITURE_DEFS.find(x => x.id === type) as FurnitureDef | undefined;
if (def == null) {
throw new Error(`Unrecognized furniture type: ${type}`);
}
return def;
}

Some files were not shown because too many files have changed in this diff Show More